diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb index 00eecfe51..619395bd6 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -84,7 +84,7 @@ "id": "277e431e" }, "outputs": [], - "source": "import sys\nsys.path.insert(0, \"/content/unsloth/studio/backend\")\nfrom colab import start\nstart()" + "source": "import sys\nsys.path.insert(0, \"/content/unsloth/studio/backend\")\nfrom colab import start\n\n# Default: in-tab iframe only. start() blocks to keep the kernel alive.\nstart()\n\n# For a shareable Cloudflare link, replace start() above with:\n# start(cloudflare=True)" }, { "cell_type": "markdown", diff --git a/studio/backend/colab.py b/studio/backend/colab.py index ba46c52a6..dd274399b 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -103,24 +103,132 @@ def show_link(port: int = 8888, *, _url: "str | None" = None): display(HTML(html)) -def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool: - """Return True if a Studio backend is already answering health checks on *port*.""" - import urllib.request +def _bootstrap_password_pending() -> bool: + """True while the default admin still owes a bootstrap-password change. + + While pending, main.py injects that password into same-origin GETs, and a public + tunnel GET (no Origin) reads as same-origin, so sharing the link would leak admin + access. Fails safe to pending if the state cannot be read. + """ try: - with urllib.request.urlopen(f"http://localhost:{port}/api/health", timeout = timeout): - return True + from auth.storage import requires_password_change, DEFAULT_ADMIN_USERNAME + return bool(requires_password_change(DEFAULT_ADMIN_USERNAME)) + except Exception as e: + logger.info(f"Could not check admin password state ({e}); refusing tunnel to be safe.") + return True + + +def start_cloudflare_tunnel(port: int) -> "str | None": + """Open a shareable Cloudflare quick tunnel to localhost:*port*, or None. + + run_server suppresses the tunnel on Colab by design, so we start it directly. + Refused while the bootstrap password is pending; any failure collapses to None + and the Colab proxy still works. + """ + if _bootstrap_password_pending(): + logger.warning( + "Cloudflare link not started: the admin account still has its temporary " + "bootstrap password, which is exposed to anyone who can load the page. " + "Open Studio in this tab, log in and change the admin password, then re-run " + "start(cloudflare=True) to get the shareable link." + ) + return None + try: + from cloudflare_tunnel import start_studio_tunnel + except Exception as e: + logger.info(f"Cloudflare tunnel unavailable ({e}); using Colab proxy only.") + return None + try: + url = start_studio_tunnel(port) + except Exception as e: + logger.info(f"Cloudflare tunnel failed to start ({e}); using Colab proxy only.") + return None + # Success is logged by _show_and_embed; note only misses here. + if not url: + logger.info("Cloudflare tunnel did not produce a URL; using Colab proxy only.") + return url + + +def _publish_cloudflare_url(cloudflare_url: "str | None") -> None: + """Publish a directly-started tunnel URL onto app.state so /api/health advertises it. + + run_server only sets this when it opens the tunnel itself, which it skips on Colab, + so we set it here. Otherwise the frontend's API examples fall back to an + unreachable server_url. Best-effort. + """ + if not cloudflare_url: + return + try: + from main import app as _studio_app + _studio_app.state.cloudflare_url = cloudflare_url + except Exception as e: + logger.info(f"Could not publish Cloudflare URL to /api/health ({e}).") + + +def _stop_cloudflare_tunnel() -> None: + """Best-effort teardown of the Cloudflare tunnel started by start_cloudflare_tunnel.""" + try: + from cloudflare_tunnel import stop_studio_tunnel + stop_studio_tunnel() + except Exception: + pass + # Stop /api/health advertising a dead tunnel. + try: + from main import app as _studio_app + _studio_app.state.cloudflare_url = None + except Exception: + pass + + +def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool: + """True only if Unsloth Studio (not some other app) answers /api/health on *port*. + + The service-marker check stops the reuse path reusing or tunneling a foreign + process that merely serves /api/health. + """ + import json, urllib.request + try: + with urllib.request.urlopen(f"http://localhost:{port}/api/health", timeout = timeout) as r: + return json.loads(r.read()).get("service") == "Unsloth UI Backend" except Exception: return False -def _show_and_embed(port: int): - """Embed the Studio inline for *port* with a branded header bar. - - Fetches the proxy URL once (registering the port), then renders header bar + - iframe. Falls back to serve_kernel_port_as_iframe if IPython HTML is unavailable. +def _shareable_link_html(cloudflare_url: str) -> str: + """Branded card for the shareable Cloudflare link, styled like the show_link banner.""" + return f""" +
+

+ + Shareable Studio Link is Ready! +

+ + + Open Unsloth Studio + +

+ This Cloudflare HTTPS link works from any device — share it with anyone. The Colab view below only works in this tab. +

+

+ 🔗 {cloudflare_url} +

+
""" + + +def _show_and_embed(port: int, *, cloudflare_url: "str | None" = None): + """Render the Studio header + iframe for *port*, with a shareable-link card above + when *cloudflare_url* is set. Falls back to serve_kernel_port_as_iframe.""" url = get_colab_url(port) logger.info(f"🌐 Unsloth Studio URL: {url}") + if cloudflare_url: + logger.info(f"🔗 Shareable Cloudflare link: {cloudflare_url}") try: from IPython.display import HTML, display @@ -136,6 +244,9 @@ def _show_and_embed(port: int): except (ValueError, IndexError): short_url = url + if cloudflare_url: + display(HTML(_shareable_link_html(cloudflare_url))) + display( HTML(f"""