fix(url): also emit #datalabBackendUrl=<full URL> fragment (#27)

Tyler committed May 27, 2026 at 10:24 UTC 8a350c192974d89bb52cf7be16da394f8b8f944b
3 files changed +159 -30
docs/04_automation_and_utility.md
+1
@@ -1,5 +1,6 @@
1 ---
2 log:
3 +2026-05-27: `colab url` now emits BOTH the `?dbu=<urlencoded path>` query parameter (existing) AND a new `#datalabBackendUrl=<full URL>` hash fragment (new). Format: `https://<host>/notebooks/empty.ipynb?dbu=%2Ftun%2Fm%2F<endpoint>#datalabBackendUrl=<host>/tun/m/<endpoint>`. Why both: some Colab frontend code paths consult the hash fragment first and ignore `dbu` entirely, so the previously-emitted query-only form failed silently for those users (the frontend fell through to allocating a fresh VM via `/tun/m/assign`). The fragment value is a FULL URL with scheme + host (NOT just the path) and is emitted RAW (no URL encoding) because browsers don't decode the fragment before passing `location.hash` to page JS — Colab's parser calls `new URL(rawString)` directly. The fragment host always matches `--host` so Colab's same-origin enforcement on embedded backend URLs doesn't block the connection, and sandbox/dev users (`--host https://colab.sandbox.google.com`) get a sandbox fragment automatically. Three new test cases in `tests/test_url.py` cover the raw-encoding requirement (`%3A`/`%2F` must NOT appear in the fragment), the both-signals-present invariant, and `--open` propagating the fragment to `webbrowser.open()`. Integration-verified live against synthetic session state with three host shapes (default, sandbox, trailing-slash); all produced correctly-shaped URLs with no `//` artifacts.
4 2026-05-07: Added a developer-only `colab whoami` subcommand (hidden from `colab --help`). Mints an access token via the same `auth.get_credentials(...)` path the rest of the CLI uses (honoring the global `--auth=...` flag), refreshes the credentials, then queries `https://oauth2.googleapis.com/tokeninfo` to print the email, scopes, audience, and expiry of whatever the CLI is about to send. Built specifically to short-circuit the "why is my call to colab.pa.googleapis.com 403-ing" debugging loop — the answer is almost always "missing scope" or "wrong identity", both of which `whoami` makes immediately visible. Hidden via `app.command(hidden=True)`; reachable via `colab whoami` or `colab whoami --help`. Suppressed from the daily-update banner check (added to `_AUTO_UPDATE_SUPPRESSED` in `cli.py`) so the banner doesn't obscure the auth output.
5 2026-05-11: Removed the local-file update source (`update_file_path` setting and `_fetch_local` helper); `colab update` now consults PyPI only. Switched the default `update_url` to the canonical PyPI JSON API (`https://pypi.org/pypi/google-colab-cli/json`), which already exposes the `info.version` schema the auto-update subsystem expects. Re-added `colab update --install` as a public self-install path that runs `pip install -U google-colab-cli` against the current `sys.executable`; Linux-only (other platforms exit non-zero with an explanatory message), and a silent no-op when the cached `latest_version` is already at or below the current install.
6 2026-05-12: Added an optional `timeout=` parameter to `ColabRuntime.execute_code` that flows through to both the `execute()` and `execute_interactive()` branches. `colab auth` and `colab drivemount` now pass `timeout=600` (10 min) via a shared `INTERACTIVE_AUTOMATION_TIMEOUT_SEC` constant in `commands/automation.py`. Background: `jupyter_kernel_client` defaults to a 10s wall-clock timeout that is consumed even when the kernel is idle waiting on `input_request`. With the drivefs hook intercepting that request and prompting the user to OAuth in their browser, any user that takes >10s to click through (essentially everyone) hit `TimeoutError` and saw "drivemount failed" even though the mount had actually succeeded server-side. The fix is scoped narrowly to the two human-in-the-loop subcommands; non-interactive paths (`colab exec`, `colab run`, `colab install`, `colab repl --pipe`, `colab console --pipe`) keep the upstream default since they receive continuous iopub traffic that resets the practical inactivity ceiling.
src/colab_cli/commands/utility.py
+37 -12
@@ -62,14 +62,27 @@ def url(
62 ):
63 """Print a browser URL that connects to an existing session.
64
65 - Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>``,
65 + Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>#datalabBackendUrl=<host>/tun/m/<endpoint>``,
66 where the path is ``/tun/m/<endpoint>``. When opened, the Colab frontend
67 skips ``/tun/m/assign`` and attaches the kernel to our existing VM.
68
69 - The ``dbu`` query parameter is the ``datalab_backend_url`` development
70 - flag. Because it's a development flag, URL-overriding it may be gated
71 - by the Colab frontend; some users may need to use the hash-based
72 - ``#datalabBackendUrl=...`` form instead.
69 + Two backend-URL signals are embedded:
70 +
71 + - ``?dbu=<urlencoded path>`` — the ``datalab_backend_url`` development
72 + query flag. The frontend resolves the value against
73 + ``window.location.origin``.
74 +
75 + - ``#datalabBackendUrl=<full URL>`` — the hash-fragment form. Some
76 + frontend code paths consult this first and ignore ``dbu``, so we
77 + emit both for robustness. The fragment value is a FULL URL (with
78 + scheme + host) and is intentionally NOT URL-encoded — browsers do
79 + not decode the fragment before passing ``location.hash`` to page
80 + JS, and Colab's hash parser expects the raw string.
81 +
82 + The fragment's host always matches ``--host`` (the page origin), so
83 + Colab's same-origin enforcement on the embedded backend URL doesn't
84 + block the connection, and sandbox/dev users get a sandbox fragment
85 + automatically.
86 """
87 # Imported here (not at module top) to mirror the lazy-state pattern used
88 # elsewhere in this module and avoid a circular import via colab_cli.common.
@@ -83,14 +96,26 @@ def url(
96 typer.echo(f"[colab] Session '{name}' not found.", err=True)
97 raise typer.Exit(code=1)
98
86 - # Strip a trailing slash so we don't produce `https://host//notebooks/...`.
99 + # Strip a trailing slash so we don't produce `https://host//notebooks/...`
100 + # or `https://host//tun/m/...` in the fragment URL.
101 host_clean = host.rstrip("/")
88 - # `dbu` value is the path `/tun/m/<endpoint>`. URL-encode it (incl. the
89 - # slashes via `safe=""`) so the value survives any downstream non-strict
90 - # query-string re-parsing — this is also the form shown in real Colab
91 - # connect URLs in the wild.
92 - dbu_value = quote(f"/tun/m/{s.endpoint}", safe="")
93 - connect_url = f"{host_clean}/notebooks/empty.ipynb?dbu={dbu_value}"
102 + backend_path = f"/tun/m/{s.endpoint}"
103 + # `dbu` value is the backend path. URL-encode it (incl. the slashes via
104 + # `safe=""`) so the value survives any downstream non-strict query-string
105 + # re-parsing — this is also the form shown in real Colab connect URLs.
106 + dbu_value = quote(backend_path, safe="")
107 + # `#datalabBackendUrl=` value is the FULL backend URL, raw (un-encoded):
108 + # the browser does not decode the fragment before passing it to page JS,
109 + # and Colab's hash parser calls `new URL(rawString)` directly. Pinning
110 + # the host to `host_clean` (not hardcoding research.google.com) keeps
111 + # this aligned with the page origin so same-origin enforcement passes
112 + # for sandbox / dev hosts too.
113 + fragment_value = f"{host_clean}{backend_path}"
114 + connect_url = (
115 + f"{host_clean}/notebooks/empty.ipynb"
116 + f"?dbu={dbu_value}"
117 + f"#datalabBackendUrl={fragment_value}"
118 + )
119
120 # Print the URL on its own line with no `[colab]` prefix so the output
121 # is pipeable (`colab url -s s1 | xclip`, etc.).
tests/test_url.py
+121 -18
@@ -17,17 +17,26 @@ frontend to an existing colab-cli session.
17
18 URL format:
19
20 - https://<host>/notebooks/empty.ipynb?dbu=<urlencoded-/tun/m/endpoint>
21 -
22 -The `dbu` query parameter is the Colab frontend's `datalab_backend_url`
23 -development flag; the frontend resolves the value against
24 -`window.location.origin` and attaches the kernel to the supplied
25 -`/tun/m/<endpoint>` path instead of allocating a fresh VM.
26 -
27 -Note: `dbu` is a development flag, so URL-overriding it may be gated by
28 -the Colab frontend; some users may need to use the hash-based
29 -`#datalabBackendUrl=...` form instead (we don't support that today; file
30 -an issue if you need it).
20 + https://<host>/notebooks/empty.ipynb?dbu=<urlencoded-/tun/m/endpoint>#datalabBackendUrl=<host>/tun/m/<endpoint>
21 +
22 +Two backend-URL signals are embedded:
23 +
24 +- `?dbu=<urlencoded path>` -- the Colab frontend's
25 + `datalab_backend_url` development query flag. The frontend resolves
26 + the value against `window.location.origin` and attaches the kernel
27 + to the supplied `/tun/m/<endpoint>` path instead of allocating a
28 + fresh VM.
29 +
30 +- `#datalabBackendUrl=<full URL>` -- the hash-fragment form. Some
31 + Colab frontend code paths consult this first and ignore `dbu`, so we
32 + emit both for robustness. The fragment value is a FULL URL (with
33 + scheme + host) and is intentionally NOT URL-encoded -- browsers do
34 + not decode fragment values before passing them to page JS, and
35 + Colab's hash parser expects the raw string.
36 +
37 +The fragment's host always matches the page origin (`--host`), so
38 +same-origin enforcement in the frontend doesn't block the connection
39 +and sandbox/dev users get a sandbox fragment automatically.
40 """
41
42 from unittest.mock import MagicMock, patch
@@ -59,10 +68,11 @@ def _parse_url_output(output: str) -> str:
68 def test_url_explicit_session(mock_common_state):
69 """`colab url -s NAME` prints the connect URL for that session.
70
62 - Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>``.
71 + Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>#datalabBackendUrl=<host>/tun/m/<endpoint>``.
72 The path must land on `empty.ipynb` so the user sees a usable notebook
73 UI; the `dbu` query param tells the frontend to skip /tun/m/assign and
65 - attach to our existing endpoint.
74 + attach to our existing endpoint; the `#datalabBackendUrl=` fragment
75 + is the alternative signal some frontend code paths consult.
76 """
77 s = _make_session(name="my-sess", endpoint="ep-XYZ")
78 mock_common_state.store.get.return_value = s
@@ -90,6 +100,12 @@ def test_url_explicit_session(mock_common_state):
100 # downstream re-parsing that treats the query string non-strictly.
101 assert "dbu=%2Ftun%2Fm%2Fep-XYZ" in url
102
103 + # Fragment: raw (not URL-encoded), full URL form, host matches page origin.
104 + assert (
105 + parsed.fragment
106 + == "datalabBackendUrl=https://colab.research.google.com/tun/m/ep-XYZ"
107 + )
108 +
109
110 def test_url_resolves_unique_session(mock_common_state):
111 """`colab url` (no -s) uses the unique-session resolution path."""
@@ -120,10 +136,16 @@ def test_url_session_not_found(mock_common_state):
136
137
138 def test_url_custom_host(mock_common_state):
123 - """`--host` overrides the default frontend host. `dbu` is a path-only
124 - value (resolved against `window.location.origin` in the frontend), so
125 - the host swap only affects the page origin, not the embedded backend
126 - path."""
139 + """`--host` overrides the default frontend host AND the host used in
140 + the `#datalabBackendUrl=` fragment.
141 +
142 + `dbu` itself is a path-only value (resolved against
143 + `window.location.origin` in the frontend), so the host swap only
144 + affects the page origin, not the embedded backend path. But the
145 + fragment carries a full URL, and the Colab frontend enforces
146 + same-origin between page and embedded backend URL -- so the fragment
147 + host MUST match `--host` for the swap to work end-to-end.
148 + """
149 s = _make_session(endpoint="ep1")
150 mock_common_state.store.get.return_value = s
151 mock_common_state.resolve_session.return_value = "s1"
@@ -138,11 +160,17 @@ def test_url_custom_host(mock_common_state):
160 assert parsed.netloc == "colab.sandbox.google.com"
161 assert parsed.path == "/notebooks/empty.ipynb"
162 assert parse_qs(parsed.query).get("dbu") == ["/tun/m/ep1"]
163 + # Fragment host tracks --host (NOT pinned to research.google.com).
164 + assert (
165 + parsed.fragment
166 + == "datalabBackendUrl=https://colab.sandbox.google.com/tun/m/ep1"
167 + )
168
169
170 def test_url_host_normalises_trailing_slash(mock_common_state):
171 """`--host https://example.com/` (with trailing slash) must not produce
145 - a double slash before `/notebooks/empty.ipynb`."""
172 + a double slash anywhere -- not before `/notebooks/empty.ipynb` in the
173 + page URL, AND not before `/tun/m/...` in the fragment value."""
174 s = _make_session(endpoint="ep2")
175 mock_common_state.store.get.return_value = s
176 mock_common_state.resolve_session.return_value = "s1"
@@ -154,6 +182,11 @@ def test_url_host_normalises_trailing_slash(mock_common_state):
182 assert result.exit_code == 0
183 assert "https://colab.research.google.com//notebooks/" not in result.output
184 assert "https://colab.research.google.com/notebooks/empty.ipynb" in result.output
185 + # Same guarantee for the fragment URL.
186 + assert "https://colab.research.google.com//tun/" not in result.output
187 + assert (
188 + "datalabBackendUrl=https://colab.research.google.com/tun/m/ep2" in result.output
189 + )
190
191
192 def test_url_endpoint_with_special_chars_is_encoded(mock_common_state):
@@ -210,6 +243,76 @@ def test_url_no_open_by_default(mock_common_state):
243 mock_open.assert_not_called()
244
245
246 +def test_url_fragment_is_not_url_encoded(mock_common_state):
247 + """The `#datalabBackendUrl=...` fragment value is a full URL and must
248 + be embedded raw (no percent-encoding). Browsers do not decode the
249 + fragment before passing `location.hash` to page JS, and the Colab
250 + parser expects to call `new URL(rawString)` directly. If we encoded
251 + `:` -> `%3A` or `/` -> `%2F` here, the parser would see
252 + `https%3A%2F%2Fcolab...` and fail.
253 +
254 + Concretely we should see the literal `://` and unescaped `/` in the
255 + fragment, NOT their percent-encoded counterparts.
256 + """
257 + s = _make_session(endpoint="ep-RAW")
258 + mock_common_state.store.get.return_value = s
259 + mock_common_state.resolve_session.return_value = "s1"
260 +
261 + result = runner.invoke(app, ["url", "-s", "s1"])
262 + assert result.exit_code == 0, result.output
263 + url = _parse_url_output(result.output)
264 + parsed = urlparse(url)
265 +
266 + # The fragment must contain the literal scheme + slashes...
267 + assert "datalabBackendUrl=https://" in url
268 + assert "/tun/m/ep-RAW" in parsed.fragment
269 + # ...and MUST NOT contain percent-encoded versions of `:`, `/`.
270 + assert "%3A" not in parsed.fragment
271 + assert "%2F" not in parsed.fragment
272 +
273 +
274 +def test_url_both_signals_present(mock_common_state):
275 + """Invariant: every printed URL has BOTH `?dbu=` and `#datalabBackendUrl=`.
276 +
277 + Either alone is unreliable across Colab frontend revisions; we emit
278 + both so the frontend can use whichever it consults first.
279 + """
280 + s = _make_session(endpoint="ep-BOTH")
281 + mock_common_state.store.get.return_value = s
282 + mock_common_state.resolve_session.return_value = "s1"
283 +
284 + result = runner.invoke(app, ["url", "-s", "s1"])
285 + assert result.exit_code == 0, result.output
286 + url = _parse_url_output(result.output)
287 + assert "?dbu=" in url
288 + assert "#datalabBackendUrl=" in url
289 +
290 +
291 +def test_url_open_flag_includes_fragment(mock_common_state):
292 + """`--open` opens the SAME URL it printed, including the fragment.
293 +
294 + `webbrowser.open()` must receive the URL with the `#datalabBackendUrl=`
295 + fragment intact -- otherwise the browser may attach to a fresh VM via
296 + `/tun/m/assign` instead of our existing session.
297 + """
298 + s = _make_session(endpoint="ep-OPEN2")
299 + mock_common_state.store.get.return_value = s
300 + mock_common_state.resolve_session.return_value = "s1"
301 +
302 + with patch("webbrowser.open") as mock_open:
303 + result = runner.invoke(app, ["url", "-s", "s1", "--open"])
304 +
305 + assert result.exit_code == 0, result.output
306 + mock_open.assert_called_once()
307 + opened_url = mock_open.call_args[0][0]
308 + assert (
309 + "#datalabBackendUrl=https://colab.research.google.com/tun/m/ep-OPEN2"
310 + in opened_url
311 + )
312 + # And it's the same URL that got printed.
313 + assert opened_url in result.output
314 +
315 +
316 def test_url_output_is_pipeable(mock_common_state):
317 """The printed URL line must be machine-parseable: a single line with no
318 leading `[colab]` chatter, so `colab url -s s1 | xclip` works.