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.