feat: add `colab ssh` — SSH-over-WebSocket runtime access (#88)

* feat: add colab ssh subcommand for SSH-over-WebSocket runtime access Implements the client side of SSH-over-WebSocket runtime access. Three modes: colab ssh Pick the only active session and open an interactive SSH shell. colab ssh -s SESSION Same, targeting SESSION explicitly. colab ssh --proxy-mode -s SESSION Act as an OpenSSH ProxyCommand-compatible WebSocket-stdio bridge, so a Host colab-runtime ProxyCommand colab ssh --proxy-mode -s SESSION block in ~/.ssh/config works with any SSH-based IDE / remote-dev tool. --identity/-i overrides the default key order (~/.ssh/id_ed25519, id_ecdsa, id_rsa); the public key is derived via `ssh-keygen -y -f` and sent verbatim in the X-Colab-Ssh-Pubkey header. Per-failure-mode error messages map each common HTTP status (400/401/403/404/ 429/502 + a network catch-all) to an actionable hint. Adds src/colab_cli/commands/ssh.py, registers it in cli.py, plus tests/test_ssh.py (24 tests), docs/06_ssh_access.md, and integration/repro_ssh/. The server side is out of scope for this repo; against a runtime that does not expose the SSH endpoint the command surfaces an actionable HTTP 404. * Add `colab ssh --drive` to mint and install a Drive backup token. Adds a --drive flag to `colab ssh`. When set, the CLI mints a drive.file-scoped OAuth token via gcloud (after the user's browser consent), reshapes it into the frozen drive_token.json schema, and installs it on the runtime at /root/.config/colab/drive_token.json with mode 0600 before opening the shell -- so in-VM code can back up notebooks to Drive. All of this is gated behind `if drive:`, so the default `colab ssh` path is byte-for-byte unchanged. - The token path, 0600 mode, drive.file scope, and JSON schema are the frozen cross-repo credential contract the in-VM reader depends on: there is no shared import across the OSS<->google3 boundary, so a later golden fixture pins the exact bytes on both sides. - gcloud is mocked in every test; no test invokes real gcloud or a browser, and the default (no --drive) path makes no gcloud or scp call. * feat(ssh): auto-create, proxy-mode flags, /content default; drop --drive Bare `colab ssh` now creates a runtime when you have none and drops you in /content; every flag also works in --proxy-mode so `~/.ssh/config` hosts (ssh colab / colab-gpu / colab-ephem) work on first connect. - Auto-create: bare `colab ssh` with no session runs `colab new` then connects; reuses a single existing session; errors on multiple. - Flags: --gpu/--tpu pick the accelerator for an auto-created runtime; --rm stops a runtime this command created when the session ends. - /content: interactive shells cd to /content (Colab's working dir) via -t and a remote `cd /content 2>/dev/null; exec $SHELL -l` (falls back to home if absent). - --proxy-mode honors all flags: `-s NAME` creates the session if missing (creation output routed to stderr so stdout stays the ssh byte stream); --rm stops the bridged session on disconnect. - --rm teardown survives OpenSSH SIGHUP: on disconnect ssh SIGHUPs the ProxyCommand (verified), which by default skipped the cleanup `finally` and leaked the runtime; now SIGHUP/SIGTERM/SIGINT handlers run the stop, idempotent with the finally. - Fix two client bugs: the 403 branch was dead (feature-off returns 404, not 403); RSA keys are server-rejected, so id_rsa is no longer auto-scanned and the 400 message no longer advertises rsa-sha2. - Remove the --drive subfeature entirely (code + tests + golden fixture). - Tests: add test_ssh_autocreate.py, test_ssh_wire_contract.py, test_ssh_workdir.py; drop the drive tests. Full suite: 280 passing. * docs(ssh): reformat 06_ssh_access to match sibling design-doc structure Restructure the ssh design doc to the same shape as the other command docs (esp. 05_run_command): Motivation -> User Surface (flags table) -> Behavior (numbered) -> AGENTS.md Constraints Honoured -> Testing Strategy (TDD). No content dropped; the frontmatter log is preserved verbatim. * style(ssh): apply go/pystyle readability to the ssh code Bring the ssh subcommand + its tests to the Google Python Style Guide (go/pystyle) readability bar: - 3.2 line length: reflow all ssh files to <=80 cols (ruff format is idempotent at --line-length 80). Repo enforces no line length, so this was the main gap. - 3.8 docstrings: add Args/Returns/Raises where it aids a caller (_resolve_pubkey, _resolve_session, _auto_create_session, _explain_handshake_failure, _connect_websocket, _bridge_proxy_mode, _run_interactive_ssh); keep summary lines <=80. - 3.8.5 comments: move overflowing trailing comments above their statements; annotate the best-effort except-cleanups (isolation points, 2.4.4). - 2.2 imports: lift stdlib signal/uuid to the top-level group (the colab_cli.* imports stay local to avoid circular imports). - Extract locals for two long f-string echoes (clearer + <=80). Unchanged (already compliant or out of scope): 4-space indentation (3.4), naming (3.16), and the from-x-import-Class convention (2.2 exemptions; repo-wide). Behavior-preserving; full suite: 280 passing. * test(ssh): turn repro_ssh into a real end-to-end integration test The old integration test only grepped `colab ssh --help`, so it could pass while the whole feature was broken. Replace it with a genuine e2e, following the repro_run_command / repro_keep_alive conventions (auth detection, isolated --config, trap cleanup, no-orphan-VM assertion). Key idea: `colab ssh --proxy-mode` is non-interactive, so it can be used as an OpenSSH ProxyCommand to run a real remote command over the WebSocket bridge -- exercising the same connect -> pubkey-header auth -> handshake -> bridge -> remote-exec path as the interactive shell, without a TTY. - Part A (offline, always runs, no VM): --help advertises the documented flags; an unknown session exits 2 with an actionable message. - Part B (live, auto-runs when auth is present; allocates a CPU VM): colab new -> substrate check (sshd up) -> `ssh root@colab-runtime "whoami"` over --proxy-mode (assert root + a unique marker) -> RSA key is rejected -> colab stop -> assert no orphan VM. The live part is no longer RUN_LIVE-gated. Interactive shell (/content, raw TTY) remains a documented manual step. Verified: offline part + full live e2e pass against prod (handshake, pubkey auth, bridge, remote exec, RSA rejection, clean teardown). Updates integration/README.md and docs/06 accordingly. * test(ssh): close coverage gaps and parameterize; fix empty-pubkey exit Fix: `_resolve_pubkey` raised a bare RuntimeError when `ssh-keygen -y` returned empty output, which escaped the surrounding `except (CalledProcessError, FileNotFoundError)` as an uncaught traceback. It now echoes an actionable message and `raise typer.Exit(code=2)`, matching every other failure path. Tests: add coverage for the previously-untested logic surfaced by a branch coverage run (ssh.py 75% -> 90%, BrPart 0): - _resolve_pubkey --identity failures (ssh-keygen error / missing / empty) - default ~/.ssh scan order incl. id_rsa NOT auto-selected + no-keys - _connect_websocket network-failure branch (address/timeout/refused/OSError) - _bridge_proxy_mode ws->stdout byte pump (binary/text/ignored-opcode/close) - _stop_session error handling (generic swallowed vs typer.Exit re-raised) - --gpu/--tpu "ignored" warnings (proxy-existing + interactive-reuse) - --identity threading through _proxy_command / _ssh_base_args Parameterize repetitive tests with readable ids: WS-URL scheme, shquote, session resolution, pubkey scan/failures, bytes-vs-str body, bare-ssh create/reuse/ambiguous, proxy-mode create/reuse, --rm teardown (proxy + interactive), signal-handler installation, and the two wire-contract mutations (-> one). Remaining uncovered lines are intentionally left: the stdin->ws thread body (covered by the live integration test), the os._exit signal-handler wrapper, and best-effort except-close cleanups. SSH tests 48 -> 68; full suite 300 passing; ruff clean; 80-col formatting held. * refactor(ssh): split ssh() into intent-named helpers; unify --gpu/--tpu warning Extract _select_proxy_session / _select_interactive_session, _run_proxy_bridge / _run_interactive_shell, _install_rm_signal_handlers, _warn_accelerator_ignored, _pubkey_from_identity, and _close_quietly so ssh() is a thin dispatcher, and convert the handshake-status mapping to a match statement. Behavior-preserving except the two divergent '--gpu/--tpu ignored' messages are unified into one; test_ssh_autocreate asserts the new wording. * test(ssh): add lifecycle guarantees; shorten test_ssh docstring New tests/test_ssh_lifecycle.py pins dispatch/lifecycle contracts the mocked suite missed: --rm teardown survives an exception (try/finally), ssh/bridge exit-code propagation, --proxy-mode stdout cleanliness (create/--rm chatter stays on stderr), auto-create failure aborts before connect, --gpu+--tpu both forwarded to 'colab new', --rm idempotency across the signal + finally paths, and reused-session --rm teardown. * docs(ssh): trim SSH design doc + integration prose; reindent repro_ssh Simplify the 06 design-doc motivation/testing sections, drop the redundant 'AGENTS.md Constraints Honoured' block, and remove the stale 'manual step' framing now that the e2e is fully automated. Reindent integration/repro_ssh/test.sh to 2-space. Adds a 2026-07-24 frontmatter log entry. * refactor(ssh): use shlex.join for the ProxyCommand instead of a hand-rolled quoter Replaces _shquote with the stdlib. Verified equivalent: all cases round-trip identically through a real shell. shlex is also stricter on non-ASCII -- _shquote left 'ünïcode' unquoted because str.isalnum() is unicode-aware, while shlex.quote uses re.ASCII. Retargets the test at the real contract: _proxy_command output must re-parse (shlex.split) into the exact argv, including hostile session names (command substitution, semicolons, quotes). Addresses review comment on PR #88. * docs(ssh): document the ProxyCommand / IDE remote-dev flow Adds a README 'SSH / IDE Remote-Dev' section and a ready-to-paste ~/.ssh/config stanza in the design doc (PATH caveat for ssh's non-login ProxyCommand shell, ephemeral --rm host, auto-create on a -s NAME miss), plus VS Code Remote-SSH / plain-ssh / one-shot examples. Fixes the one-shot examples to connect as root@: the runtime authorizes root (_SSH_HOST = root@colab-runtime), so without it ssh would default to the local username and fail auth. %h expands to the host only, so -s %h is unaffected. * Revert "docs(ssh): document the ProxyCommand / IDE remote-dev flow" This reverts commit 7691d4ef5210595e08787a5a7ea1c871da0e22ba. * test(ssh): fold the ProxyCommand argv prefix into one assertion sethtroisi asked for this shape in review. Comparing the whole 7-element prefix at once also fixes the failure mode: the old argv[5]/argv[6] index accesses raised IndexError on a short argv instead of failing cleanly as an assertion. --------- Co-authored-by: Drake Aiman <drakeaiman@google.com>

rabbit committed Jul 29, 2026 at 18:16 UTC c129cbfd6404d3e73f3994d21b9166a907313d76
11 files changed +2517 -1
README.md
+1
@@ -82,6 +82,7 @@ Run `colab <command> --help` to view specific options, defaults, and detailed he
82 | `colab exec [-s NAME] [-f FILE] [--output-image PATH]` | Execute Python code from stdin, a local `.py` file, or a `.ipynb` notebook |
83 | `colab repl [-s NAME] [--output-image PATH]` | Start an interactive Python REPL on the VM (exits cleanly on piped EOF) |
84 | `colab console [-s NAME]` | Connect to a raw interactive TTY shell (tmux) on the remote VM |
85 +| `colab ssh [-s NAME] [--proxy-mode] [-i KEY]` | Open an SSH shell to the runtime over WebSocket, or act as an OpenSSH `ProxyCommand` bridge for IDE remote-dev |
86
87 ### File Operations
88 | Command | Description |
docs/06_ssh_access.md new
+135
@@ -0,0 +1,135 @@
1 +---
2 +log:
3 +2026-07-17: Initial design and implementation of `colab ssh` — client side of SSH-over-WebSocket runtime access. Adds three modes (interactive shell, `-s SESSION`, and `--proxy-mode` OpenSSH ProxyCommand bridge), `--identity/-i` key selection, and per-HTTP-status handshake error messages. Server side is out of scope for this repo; the subcommand is a no-op against runtimes that do not expose the `/colab/ssh` endpoint (surfaces an actionable HTTP 404 message).
4 +2026-07-22: Bare `colab ssh` now auto-creates a runtime (via `colab new`) when you have no active session, with `--gpu/--tpu` passthrough and `--rm` to stop an auto-created runtime on exit. Fixed two client bugs: the dead 403 branch (feature-off returns 404, not 403) and the RSA guidance (all `ssh-rsa` keys are server-rejected, so `id_rsa` is no longer auto-scanned and the 400 message no longer advertises `rsa-sha2`). Added `tests/test_ssh_wire_contract.py` (real loopback-server wire assertions) and `tests/test_ssh_autocreate.py`.
5 +2026-07-22: Interactive `colab ssh` now starts in `/content` (Colab's working dir) instead of `/root`, via a forced PTY (`-t`) plus a remote `cd /content 2>/dev/null; exec $SHELL -l`. A missing `/content` falls back to the login home. Added `tests/test_ssh_workdir.py`.
6 +2026-07-22: `--proxy-mode` now honors every `colab ssh` flag: with `-s NAME` it creates the session if missing (creation output routed to stderr so stdout stays the clean ssh byte stream), `--gpu/--tpu` set the accelerator, and `--rm` stops the bridged session on disconnect — so `~/.ssh/config` hosts work on first connect and can be made ephemeral. Removed the `--drive` subfeature entirely (code + tests).
7 +2026-07-22: Fixed `--proxy-mode --rm` not tearing down on disconnect. OpenSSH sends the ProxyCommand SIGHUP (verified empirically) when the session ends — not just stdin EOF — and Python's default SIGHUP action terminated the process before the teardown `finally` ran, leaking the runtime + keep-alive daemon. Now `--rm` installs SIGHUP/SIGTERM/SIGINT handlers that run the stop (idempotent with the `finally`).
8 +2026-07-23: Applied go/pystyle readability to the ssh code (80-col reflow, Args/Returns/Raises docstrings) and upgraded the integration test from a `--help` smoke into a real end-to-end: it drives a live remote command over `colab ssh --proxy-mode` used as an OpenSSH ProxyCommand (handshake + pubkey-header auth + bridge + remote exec), asserts the RSA-key rejection, and verifies no orphan VM — plus an always-on offline check (help flags + unknown-session exit 2). The live part now auto-runs when auth is present instead of being `RUN_LIVE`-gated.
9 +2026-07-24: Refactored `ssh()` into intent-named helpers (`_select_proxy_session`, `_select_interactive_session`, `_run_proxy_bridge`, `_run_interactive_shell`, `_install_rm_signal_handlers`, `_warn_accelerator_ignored`) — behavior-preserving — and unified the two `--gpu/--tpu ignored` messages into one. Added `tests/test_ssh_lifecycle.py` pinning lifecycle guarantees: `--rm` teardown survives an exception (try/finally), ssh/bridge exit-code propagation, `--proxy-mode` stdout cleanliness (create/`--rm` chatter stays on stderr), auto-create failure aborts before connect, `--gpu`+`--tpu` both forwarded to `colab new`, `--rm` idempotency across the signal + finally paths, and reused-session `--rm` teardown. Trimmed prose in this doc + the integration README.
10 +---
11 +
12 +# Design: `colab ssh` — SSH-over-WebSocket Runtime Access
13 +
14 +## Motivation
15 +Users want a real shell on their Colab runtime and, more importantly, IDE
16 +remote-development (VS Code Remote-SSH, JetBrains Gateway, plain `ssh`). `colab ssh` is
17 +the client that allows sshing into Colab, reusing the CLI's existing session resolution and
18 +runtime-proxy token so no separate credential handling is needed.
19 +
20 +## User Surface
21 +
22 +```
23 +colab ssh [OPTIONS]
24 +```
25 +
26 +| Flag | Type | Default | Purpose |
27 +|---|---|---|---|
28 +| `-s`, `--session` | str | auto | Session to connect to. If omitted, uses your only active session, auto-creates one when you have none, or errors when you have several. |
29 +| `--proxy-mode` | bool | False | Act as an OpenSSH `ProxyCommand`-compatible WebSocket↔stdio bridge (reads stdin, writes stdout) for `~/.ssh/config`. Every other flag still applies. |
30 +| `-i`, `--identity` | str | auto | Private key for the public key sent to Colab. Default: first of `~/.ssh/id_ed25519`, `id_ecdsa`. |
31 +| `--gpu` | str | None | GPU accelerator for a runtime this command creates (T4, L4, G4, H100, A100). |
32 +| `--tpu` | str | None | TPU accelerator for a runtime this command creates (v5e1, v6e1). |
33 +| `--rm` | bool | False | Stop the runtime when the session ends. Interactive: only a runtime `colab ssh` auto-created (a reused session is never removed). `--proxy-mode`: the bridged session, on disconnect. |
34 +
35 +### `~/.ssh/config` usage
36 +`--proxy-mode` turns `colab ssh` into a transport any SSH-based tool can drive:
37 +
38 +```
39 +Host <alias>
40 + ProxyCommand <abs-path-to>/colab ssh --proxy-mode -s <name> [--gpu T4] [--rm]
41 + User root
42 + StrictHostKeyChecking no
43 + UserKnownHostsFile /dev/null
44 +```
45 +
46 +Because every flag applies in `--proxy-mode`, `-s <name>` creates the session on
47 +first connect, `--gpu/--tpu` size it, and `--rm` makes the host ephemeral. Use an
48 +**absolute** `colab` path: `ssh` runs the `ProxyCommand` in a non-login shell
49 +where a bare `colab` may not be on `PATH`. External SSH tools run their own
50 +remote command, so to also land in `/content` add `RequestTTY yes` and
51 +`RemoteCommand cd /content 2>/dev/null; exec bash -l`.
52 +
53 +## Behavior
54 +
55 +1. **Session resolution / auto-create**: With `-s NAME`, resolves that session
56 + (via `state.resolve_session`, the same helper the other commands use). Bare
57 + `colab ssh` uses your only active session; with **no** session it auto-creates
58 + one (mirrors `colab new` end-to-end: assign → keep-alive pre-flight → spawn
59 + keep-alive daemon → persist `SessionState`); with **multiple** it errors and
60 + asks you to pick one with `-s`.
61 +2. **Connect**: Opens the WebSocket to `wss://<netloc>/colab/ssh?colab-runtime-proxy-token=<token>`
62 + and sends the resolved public key verbatim in the `X-Colab-Ssh-Pubkey` header
63 + (no transformation -- the bytes the user controls are exactly what the server
64 + receives). Only `ssh-ed25519` / `ecdsa-sha2-nistp{256,384,521}` keys are
65 + accepted.
66 +3. **Interactive shell**: Spawns the system `ssh` binary with the CLI re-invoked
67 + as its own `ProxyCommand` (`python -m colab_cli.cli ssh --proxy-mode`), so the
68 + WebSocket bridge and the interactive shell share one code path. It forces a
69 + PTY (`-t`) and runs `cd /content 2>/dev/null; exec $SHELL -l` so you land in
70 + `/content` (Colab's working dir) rather than root's home; a missing `/content`
71 + falls back to the login home.
72 +4. **`--proxy-mode` bridge**: Bridges the WebSocket ↔ stdin/stdout for use as an
73 + OpenSSH `ProxyCommand`. Honors every flag: `-s NAME` creates the session if
74 + missing (creation output routed to stderr so stdout stays the clean ssh byte
75 + stream); bare `--proxy-mode` with no `-s` just resolves an existing session.
76 +5. **`--rm` teardown**: Stops the runtime when the session ends. In `--proxy-mode`
77 + this must survive how OpenSSH ends a `ProxyCommand`: on disconnect it sends
78 + **SIGHUP** (verified), not just stdin EOF, and Python's default SIGHUP action
79 + would terminate the process before the teardown `finally` ran — leaking the
80 + runtime and its keep-alive daemon. `--rm` therefore installs
81 + SIGHUP/SIGTERM/SIGINT handlers that run the stop, idempotent with the
82 + `finally`. `SIGKILL` cannot be intercepted, so a `kill -9`/hard crash can
83 + still leak; a normal disconnect is SIGHUP and is handled.
84 +6. **Error handling**: The WebSocket upgrade maps each common HTTP status to an
85 + actionable message:
86 +
87 + | Status | Meaning surfaced to the user |
88 + | --- | --- |
89 + | 400 | Bad/unsupported/missing pubkey, with remediation (`ssh-keygen -t ed25519`) |
90 + | 401 | Token invalid/expired — try `colab new` |
91 + | 403 | Forbidden — token lacks permission for this action (feature-off returns 404, not 403) |
92 + | 404 | SSH not exposed on this runtime — SSH is baked in at creation, so run `colab new` |
93 + | 429 | Another `colab ssh` is already connected — disconnect first |
94 + | 502 | Runtime `sshd` unreachable — runtime may be unhealthy |
95 + | other / none | Raw status or a network-check hint |
96 +
97 +
98 +## Testing Strategy (TDD)
99 +
100 +### Unit tests (`tests/test_ssh.py`)
101 +1. WebSocket URL construction (`wss` for https, `ws` for http; token query param).
102 +2. Pubkey resolution — `--identity` (via `ssh-keygen -y -f`) and the `~/.ssh`
103 + default scan; missing-key and missing-identity exit paths.
104 +3. The full status→message map (400/401/403/404/429/502/other/none).
105 +4. Shell quoting for the `ProxyCommand` string.
106 +5. Session resolution (existing vs missing).
107 +6. End-to-end dispatch: interactive vs `--proxy-mode`, including a
108 + verbatim-pubkey pass-through assertion and the actionable-400 message.
109 +
110 +### Wire-contract tests (`tests/test_ssh_wire_contract.py`)
111 +Stands up a loopback WebSocket server and drives the real connect path (no mock)
112 +to assert the request path, the `colab-runtime-proxy-token` query param, and the
113 +`X-Colab-Ssh-Pubkey` header reach the wire verbatim. Includes mutation tests that
114 +fail if `_SSH_PATH`/`_PUBKEY_HEADER` drift, plus real HTTP 400/429 mapping via a
115 +genuine `WebSocketBadStatusException`.
116 +
117 +### Auto-create & proxy-mode tests (`tests/test_ssh_autocreate.py`)
118 +Bare `colab ssh` create vs reuse vs ambiguous; `--gpu/--tpu` passthrough; `--rm`
119 +stop-on-exit; and the `--proxy-mode` matrix — create-if-missing with `-s NAME`,
120 +reuse of an existing session, `--gpu` passthrough, `--rm` teardown, and the
121 +SIGHUP cleanup handler being installed only under `--rm`.
122 +
123 +### Working-directory tests (`tests/test_ssh_workdir.py`)
124 +Interactive `ssh` forces a PTY (`-t`) and runs a `cd /content` remote command
125 +(host before the command, `2>/dev/null` tolerance for a missing directory).
126 +
127 +### Integration test (`integration/repro_ssh/`)
128 +Two parts. An offline smoke that always runs (no VM): ``--help`` advertises the
129 +documented flags, and an unknown session exits 2 with an actionable message. A
130 +live end-to-end that runs when auth is present (allocates a CPU VM): it uses
131 +``colab ssh --proxy-mode`` as an OpenSSH ProxyCommand to run a real remote
132 +command over the WebSocket bridge -- exercising the same connect ->
133 +pubkey-header auth -> handshake -> bridge -> remote-exec path as the interactive
134 +shell, minus the TTY -- asserts the RSA-key rejection, and verifies ``colab
135 +stop`` leaves no orphan VM.
integration/README.md
+1
@@ -17,6 +17,7 @@ End-to-end tests that run against a **live Colab backend** (unlike the mocked un
17 | `repro_variable_persistence/` | Variables persist across `colab exec` calls in the same session. |
18 | `repro_piped_console/` | Fast smoke test (~5s including session creation): `echo cmd \| colab console -s s` runs the command and exits within 30s. Regression test for the 2026-05-07 EOF-handler fix. |
19 | `repro_bundled_oauth/` | Fast smoke test (~5s): verifies that the fallback OAuth configuration is loaded and starts the OAuth flow with the default client ID when local config is missing. |
20 +| `repro_ssh/` | Fast smoke test (~5s): `--help` advertises the flags and an unknown session exits. Slow soak test (~95s): Live e2e allocates a CPU VM, runs a real remote command over `colab ssh --proxy-mode` |
21
22
23 ## Running
integration/repro_ssh/test.sh new
+189
@@ -0,0 +1,189 @@
1 +#!/bin/bash
2 +# Copyright 2026 Google LLC
3 +#
4 +# Licensed under the Apache License, Version 2.0 (the "License");
5 +# you may not use this file except in compliance with the License.
6 +# You may obtain a copy of the License at
7 +#
8 +# http://www.apache.org/licenses/LICENSE-2.0
9 +#
10 +# Unless required by applicable law or agreed to in writing, software
11 +# distributed under the License is distributed on an "AS IS" BASIS,
12 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +# See the License for the specific language governing permissions and
14 +# limitations under the License.
15 +
16 +# Integration Test: `colab ssh`
17 +#
18 +# Part A (offline, always runs; no VM): `--help` advertises the documented
19 +# flags, and an unknown session exits 2 with an actionable message.
20 +#
21 +# Part B (live; runs when auth is available; allocates a CPU VM): a genuine
22 +# end-to-end. `colab ssh --proxy-mode` is non-interactive, so we use it as an
23 +# OpenSSH ProxyCommand and run a real remote command over the WebSocket
24 +# bridge. This exercises the SAME connect -> pubkey-header -> handshake ->
25 +# bridge path as the interactive shell, minus the TTY:
26 +# colab new -> substrate check (sshd up) -> `ssh root@... "whoami"` over the
27 +# bridge -> assert it ran as root on the runtime -> RSA-key rejection ->
28 +# colab stop -> assert no orphan VM.
29 +
30 +# Do not `set -e`: we capture failures explicitly so cleanup always runs.
31 +set -u
32 +
33 +# ---------- Part A: offline smoke (always runs, no VM) -----------------------
34 +echo "== A1: colab ssh --help advertises the documented flags =="
35 +HELP="$(uv run colab ssh --help 2>&1)"
36 +echo "$HELP"
37 +
38 +fail=0
39 +for needle in "--proxy-mode" "--identity" "--session" "--gpu" "--tpu" "--rm" \
40 + "Connect to a Colab runtime via SSH"; do
41 + if ! printf '%s' "$HELP" | grep -q -- "$needle"; then
42 + echo "FAIL: '$needle' missing from 'colab ssh --help'"
43 + fail=1
44 + fi
45 +done
46 +
47 +echo "== A2: an unknown session exits 2 with an actionable message (offline) =="
48 +OFFLINE_CFG="$(mktemp -d)/sessions.json"
49 +A2_OUT="$(uv run colab --config "$OFFLINE_CFG" ssh -s ghost-no-such-session 2>&1)"
50 +A2_RC=$?
51 +echo "$A2_OUT"
52 +if [ "$A2_RC" -ne 2 ]; then
53 + echo "FAIL: expected exit 2 for an unknown session, got $A2_RC"
54 + fail=1
55 +fi
56 +if ! printf '%s' "$A2_OUT" | grep -q "not found"; then
57 + echo "FAIL: expected a 'not found' message for an unknown session"
58 + fail=1
59 +fi
60 +
61 +if [ "$fail" -ne 0 ]; then
62 + echo "OFFLINE SMOKE FAILED"
63 + exit 1
64 +fi
65 +echo "OFFLINE SMOKE PASSED"
66 +
67 +# ---------- Part B: live end-to-end (needs auth + a VM) ----------------------
68 +# Auth detection (mirrors integration/repro_run_command/test.sh).
69 +if [ -f "$HOME/.config/colab-cli/token.json" ]; then
70 + AUTH_FLAGS="--auth=oauth2"
71 +elif command -v gcloud >/dev/null && gcloud auth application-default print-access-token >/dev/null 2>&1; then
72 + ADC_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
73 + ADC_SCOPES=$(curl -s "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=$ADC_TOKEN" | python3 -c "import json,sys; print(json.load(sys.stdin).get('scope',''))" 2>/dev/null)
74 + if echo "$ADC_SCOPES" | grep -q "colaboratory" && echo "$ADC_SCOPES" | grep -q "userinfo.email"; then
75 + AUTH_FLAGS="--auth=adc"
76 + else
77 + echo "[skip] live e2e: ADC token lacks the required scopes"
78 + echo " (colaboratory + userinfo.email). Offline smoke passed."
79 + exit 0
80 + fi
81 +else
82 + echo "[skip] live e2e: no usable auth provider (OAuth2 token or scoped ADC)."
83 + echo " Offline smoke passed."
84 + exit 0
85 +fi
86 +echo "[*] Using $AUTH_FLAGS"
87 +
88 +if ! command -v ssh >/dev/null || ! command -v ssh-keygen >/dev/null; then
89 + echo "[skip] live e2e: OpenSSH client (ssh/ssh-keygen) not found."
90 + exit 0
91 +fi
92 +
93 +TMP_DIR=$(mktemp -d)
94 +SESSION_FILE="$TMP_DIR/sessions.json"
95 +KEY="$TMP_DIR/id_ed25519"
96 +RSA_KEY="$TMP_DIR/id_rsa"
97 +SESSION_NAME="repro-ssh-$(date +%s)"
98 +
99 +cleanup() {
100 + echo "[*] Cleaning up..."
101 + uv run colab $AUTH_FLAGS --config "$SESSION_FILE" stop -s "$SESSION_NAME" \
102 + 2>/dev/null || true
103 + rm -rf "$TMP_DIR"
104 +}
105 +trap cleanup EXIT
106 +
107 +# Throwaway ed25519 key (RSA is server-rejected) so we never touch ~/.ssh.
108 +ssh-keygen -t ed25519 -N "" -f "$KEY" >/dev/null
109 +
110 +echo "[*] Creating runtime '$SESSION_NAME' (REAL API CALL)..."
111 +if ! uv run colab $AUTH_FLAGS --config "$SESSION_FILE" new -s "$SESSION_NAME"; then
112 + echo "[FAILURE] colab new failed."
113 + exit 1
114 +fi
115 +
116 +echo "[*] Substrate check: is sshd listening on the runtime?"
117 +SUB=$(
118 + cat <<'PY' | uv run colab $AUTH_FLAGS --config "$SESSION_FILE" exec -s "$SESSION_NAME" 2>&1
119 +import subprocess
120 +cmd = "pgrep -x sshd >/dev/null && echo SSHD_UP || echo SSHD_DOWN"
121 +print(subprocess.run(["bash", "-lc", cmd], capture_output=True, text=True).stdout.strip())
122 +PY
123 +)
124 +echo "$SUB"
125 +if ! echo "$SUB" | grep -q "SSHD_UP"; then
126 + echo "[FAILURE] sshd is not running on the runtime. The prod SSH substrate"
127 + echo " (COLAB_ENABLE_SSH) is not present here, so the end-to-end"
128 + echo " flow cannot succeed. This is an environment/prod issue, not"
129 + echo " a client bug."
130 + exit 1
131 +fi
132 +
133 +echo "[*] End-to-end: run a remote command over --proxy-mode (non-interactive)."
134 +MARKER="ssh-ok-$$-$RANDOM"
135 +PROXY="uv run colab $AUTH_FLAGS --config $SESSION_FILE ssh --proxy-mode -s $SESSION_NAME -i $KEY"
136 +E2E_OUT=$(
137 + timeout 120 ssh -F /dev/null \
138 + -o "ProxyCommand=$PROXY" \
139 + -o StrictHostKeyChecking=no \
140 + -o UserKnownHostsFile=/dev/null \
141 + -o BatchMode=yes \
142 + -o ConnectTimeout=60 \
143 + -o LogLevel=ERROR \
144 + -i "$KEY" \
145 + root@colab-runtime "whoami; echo $MARKER" 2>&1
146 +)
147 +E2E_RC=$?
148 +echo "$E2E_OUT"
149 +if [ "$E2E_RC" -ne 0 ]; then
150 + echo "[FAILURE] ssh over --proxy-mode exited $E2E_RC."
151 + exit 1
152 +fi
153 +if ! echo "$E2E_OUT" | grep -qx "root"; then
154 + echo "[FAILURE] remote 'whoami' did not report root."
155 + exit 1
156 +fi
157 +if ! echo "$E2E_OUT" | grep -q "$MARKER"; then
158 + echo "[FAILURE] remote marker '$MARKER' missing — the command did not run"
159 + echo " on the runtime."
160 + exit 1
161 +fi
162 +echo "[SUCCESS] Handshake + pubkey-auth + bridge + remote exec all work."
163 +
164 +echo "[*] Negative: an RSA key is rejected by the server (non-interactive)."
165 +ssh-keygen -t rsa -b 2048 -N "" -f "$RSA_KEY" >/dev/null
166 +RSA_OUT=$(
167 + uv run colab $AUTH_FLAGS --config "$SESSION_FILE" ssh --proxy-mode \
168 + -s "$SESSION_NAME" -i "$RSA_KEY" </dev/null 2>&1
169 +)
170 +echo "$RSA_OUT"
171 +if ! echo "$RSA_OUT" | grep -qiE "unsupported key type|HTTP 400"; then
172 + echo "[FAILURE] RSA key did not surface the expected 'unsupported key type'"
173 + echo " / HTTP 400 rejection."
174 + exit 1
175 +fi
176 +echo "[SUCCESS] RSA key correctly rejected with an actionable message."
177 +
178 +echo "[*] Stopping session (REAL API CALL)..."
179 +uv run colab $AUTH_FLAGS --config "$SESSION_FILE" stop -s "$SESSION_NAME"
180 +SESSIONS_OUT=$(uv run colab $AUTH_FLAGS --config "$SESSION_FILE" sessions 2>&1)
181 +echo "$SESSIONS_OUT"
182 +if ! echo "$SESSIONS_OUT" | grep -q "No active sessions found on server."; then
183 + echo "[FAILURE] After stop, the server still reports active sessions"
184 + echo " (possible orphan VM — investigate)."
185 + exit 1
186 +fi
187 +
188 +echo "[SUCCESS] All live SSH end-to-end checks passed."
189 +exit 0
src/colab_cli/cli.py
+2 -1
@@ -23,7 +23,7 @@ from typing_extensions import Annotated
23 from colab_cli import auto_update
24 from colab_cli.auth import AuthProvider
25 from colab_cli.common import state, setup_logging
26 -from colab_cli.commands import session, execution, files, automation, run, utility
26 +from colab_cli.commands import session, execution, files, automation, run, ssh, utility
27
28
29 class AlphabeticalGroup(TyperGroup):
@@ -144,6 +144,7 @@ execution.register(app)
144 files.register(app)
145 automation.register(app)
146 run.register(app)
147 +ssh.register(app)
148 utility.register(app)
149
150
src/colab_cli/commands/ssh.py new
+682
@@ -0,0 +1,682 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Connect to a Colab runtime through ssh.
16 +
17 +Modes:
18 +
19 +* ``colab ssh`` use your only active session (or auto-create
20 + one) and open an interactive shell in
21 + ``/content``.
22 +* ``colab ssh -s SESSION`` same, targeting SESSION explicitly.
23 +* ``colab ssh --proxy-mode -s S`` act as an OpenSSH ProxyCommand-compatible
24 + WebSocket-stdio bridge for ``~/.ssh/config``.
25 +
26 +Every ``colab ssh`` flag also works in ``--proxy-mode``: with ``-s NAME`` the
27 +session is created if it does not exist (so a config host works on first
28 +connect), ``--gpu/--tpu`` pick the accelerator for that auto-created runtime,
29 +and ``--rm`` stops the runtime when you disconnect.
30 +
31 +``--identity/-i`` overrides the default key order (``~/.ssh/id_ed25519`` ->
32 +``id_ecdsa``); the public key is derived via ``ssh-keygen -y -f`` and sent in
33 +the ``X-Colab-Ssh-Pubkey`` header. RSA keys are rejected by the server, so
34 +``id_rsa`` is not auto-selected.
35 +"""
36 +
37 +import contextlib
38 +import os
39 +from pathlib import Path
40 +import select
41 +import shlex
42 +import signal
43 +import subprocess
44 +import sys
45 +import threading
46 +from typing import Callable, Optional
47 +from urllib.parse import urlparse
48 +import uuid
49 +
50 +from colab_cli.state import SessionState
51 +import typer
52 +from typing_extensions import Annotated
53 +import websocket
54 +
55 +_SSH_PATH = "/colab/ssh"
56 +# ssh-rsa keys are rejected server-side, so they are not auto-selected.
57 +_KEY_TYPES = ["id_ed25519.pub", "id_ecdsa.pub"]
58 +_PUBKEY_HEADER = "X-Colab-Ssh-Pubkey"
59 +_SSH_HOST = "root@colab-runtime"
60 +# Colab's standard working directory; land here instead of root's home.
61 +_DEFAULT_REMOTE_DIR = "/content"
62 +
63 +
64 +def _pubkey_from_identity(identity: str) -> str:
65 + """Derives the public key from a private key via ``ssh-keygen -y -f``.
66 +
67 + Args:
68 + identity: Path to the private key (``~`` is expanded).
69 +
70 + Returns:
71 + The public key text.
72 +
73 + Raises:
74 + typer.Exit: If the file is missing, ssh-keygen fails, or it yields no key
75 + (exit code 2).
76 + """
77 + identity = os.path.expanduser(identity)
78 + if not os.path.exists(identity):
79 + typer.echo(f"[colab] --identity {identity}: file not found.", err=True)
80 + raise typer.Exit(code=2)
81 + try:
82 + res = subprocess.run(
83 + ["ssh-keygen", "-y", "-f", identity],
84 + check=True,
85 + capture_output=True,
86 + text=True,
87 + )
88 + except (subprocess.CalledProcessError, FileNotFoundError) as e:
89 + typer.echo(
90 + f"[colab] failed to derive public key from {identity}: {e}",
91 + err=True,
92 + )
93 + raise typer.Exit(code=2)
94 + pubkey = res.stdout.strip()
95 + if not pubkey:
96 + typer.echo(
97 + f"[colab] ssh-keygen produced no key for {identity}.", err=True
98 + )
99 + raise typer.Exit(code=2)
100 + return pubkey
101 +
102 +
103 +def _resolve_pubkey(identity: Optional[str]) -> str:
104 + """Returns the public key for the ``X-Colab-Ssh-Pubkey`` header.
105 +
106 + With ``identity``, derives it from that private key; otherwise scans
107 + ``~/.ssh`` for the first existing ``id_<type>.pub`` in preference order.
108 +
109 + Args:
110 + identity: Path to a private key, or None to scan ``~/.ssh``.
111 +
112 + Returns:
113 + The public key text to send verbatim in the header.
114 +
115 + Raises:
116 + typer.Exit: If no usable key is found (exit code 2).
117 + """
118 + if identity:
119 + return _pubkey_from_identity(identity)
120 +
121 + ssh_dir = Path(os.path.expanduser("~/.ssh"))
122 + for name in _KEY_TYPES:
123 + candidate = ssh_dir / name
124 + if candidate.exists():
125 + return candidate.read_text().strip()
126 + typer.echo(
127 + "[colab] no SSH public key found in ~/.ssh/. Run "
128 + "`ssh-keygen -t ed25519` to generate one, or pass --identity.",
129 + err=True,
130 + )
131 + raise typer.Exit(code=2)
132 +
133 +
134 +def _resolve_session(name: Optional[str]) -> SessionState:
135 + """Resolves the named session, or exits with an actionable message.
136 +
137 + Args:
138 + name: The session name, or None to use the single active session.
139 +
140 + Returns:
141 + The resolved ``SessionState``.
142 +
143 + Raises:
144 + typer.Exit: If the session cannot be resolved (exit code 2).
145 + """
146 + from colab_cli.common import state
147 +
148 + resolved = state.resolve_session(name)
149 + s = state.store.get(resolved)
150 + if not s:
151 + typer.echo(
152 + f"[colab] session '{resolved}' not found. "
153 + "Run `colab sessions` to list active sessions.",
154 + err=True,
155 + )
156 + raise typer.Exit(code=2)
157 + return s
158 +
159 +
160 +def _session_exists(name: str) -> bool:
161 + """True if a session with this exact name is in the local store."""
162 + from colab_cli.common import state
163 +
164 + return state.store.get(name) is not None
165 +
166 +
167 +def _has_local_sessions() -> bool:
168 + """True if the local store has any session.
169 +
170 + Gates bare ``colab ssh`` auto-create: we create only when there are zero
171 + sessions, matching ``state.resolve_session`` (which errors on an empty
172 + store).
173 + """
174 + from colab_cli.common import state
175 +
176 + return bool(state.store.list())
177 +
178 +
179 +def _auto_create_session(
180 + gpu: Optional[str], tpu: Optional[str], name: Optional[str] = None
181 +) -> SessionState:
182 + """Creates a runtime via ``colab new`` and returns its session.
183 +
184 + Reuses ``colab new``'s creation path (assignment, keep-alive daemon, scope
185 + pre-flight) verbatim so the two commands cannot drift.
186 +
187 + Args:
188 + gpu: GPU accelerator to request, or None for CPU.
189 + tpu: TPU accelerator to request, or None for CPU.
190 + name: Session name to pin; a random one is generated when omitted.
191 +
192 + Returns:
193 + The newly created ``SessionState``.
194 + """
195 + from colab_cli.commands import session as session_cmd
196 +
197 + name = name or uuid.uuid4().hex[:6]
198 + typer.echo(f"[colab] Creating runtime '{name}'...")
199 + session_cmd.new(session=name, gpu=gpu, tpu=tpu)
200 + return _resolve_session(name)
201 +
202 +
203 +def _stop_session(name: str) -> None:
204 + """Best-effort ``colab stop`` for a session (used by ``--rm``)."""
205 + from colab_cli.commands import session as session_cmd
206 +
207 + try:
208 + session_cmd.stop(session=name)
209 + except typer.Exit:
210 + raise
211 + except Exception as e: # Cleanup must not mask the shell's own exit.
212 + typer.echo(f"[colab] --rm: failed to stop '{name}': {e}", err=True)
213 +
214 +
215 +def _build_ws_url(session: SessionState) -> str:
216 + """Builds the WebSocket URL for the session's SSH endpoint."""
217 + parsed = urlparse(session.url)
218 + scheme = "wss" if parsed.scheme == "https" else "ws"
219 + return (
220 + f"{scheme}://{parsed.netloc}{_SSH_PATH}"
221 + f"?colab-runtime-proxy-token={session.token}"
222 + )
223 +
224 +
225 +def _explain_handshake_failure(status: Optional[int], body: bytes) -> str:
226 + """Maps an upgrade-handshake status/body to an actionable message.
227 +
228 + Args:
229 + status: The HTTP status of the failed upgrade, or None if there was no
230 + HTTP status (e.g. a network error).
231 + body: The raw response body, used to refine the 400 message.
232 +
233 + Returns:
234 + A human-readable, actionable error message.
235 + """
236 + snippet = body.decode("utf-8", errors="replace").strip()[:200]
237 + match status:
238 + case 400 if "missing pubkey" in snippet:
239 + message = (
240 + "Server rejected request: missing pubkey header. This is "
241 + "likely a CLI bug; please file a colab-cli issue."
242 + )
243 + case 400 if "unsupported key type" in snippet:
244 + message = (
245 + "Server rejected pubkey: unsupported key type. Accepted "
246 + "key types: ssh-ed25519, ecdsa-sha2-nistp{256,384,521}. RSA "
247 + "keys (ssh-rsa) are NOT accepted. Generate an Ed25519 key "
248 + "with `ssh-keygen -t ed25519` and re-run (optionally with "
249 + "--identity)."
250 + )
251 + case 400:
252 + message = (
253 + f"Server rejected pubkey (HTTP 400): {snippet}. "
254 + "Re-check your key with `ssh-keygen -y -f <key>`."
255 + )
256 + case 401:
257 + message = (
258 + "Authentication failed (HTTP 401): the runtime-proxy token "
259 + "is invalid. The session may have expired - try `colab new`."
260 + )
261 + case 403:
262 + message = (
263 + "Forbidden (HTTP 403): the server refused this request; the "
264 + "runtime-proxy token may lack permission for this action. (A "
265 + "runtime without SSH enabled returns 404, not 403.)"
266 + )
267 + case 404:
268 + message = (
269 + "Endpoint not found (HTTP 404): this runtime does not expose "
270 + "the /colab/ssh endpoint. SSH is enabled at runtime creation, "
271 + "so an older or non-SSH runtime will not have it - run "
272 + "`colab new` for a fresh runtime with SSH."
273 + )
274 + case 429:
275 + message = (
276 + "Already-active SSH session (HTTP 429): another `colab ssh` "
277 + "is connected to this runtime. Disconnect it and retry."
278 + )
279 + case 502:
280 + message = (
281 + "Bad gateway (HTTP 502): the runtime's local sshd is "
282 + "unreachable. The runtime may be unhealthy; try `colab "
283 + "status`, then `colab stop` + `colab new`."
284 + )
285 + case None:
286 + message = (
287 + "WebSocket upgrade failed without an HTTP status: "
288 + f"{snippet}. Check your network."
289 + )
290 + case _:
291 + message = f"WebSocket upgrade rejected (HTTP {status}): {snippet}"
292 + return message
293 +
294 +
295 +def _connect_websocket(url: str, pubkey: str) -> websocket.WebSocket:
296 + """Opens the WebSocket, mapping handshake failures to messages.
297 +
298 + Args:
299 + url: The ``wss://.../colab/ssh`` URL to connect to.
300 + pubkey: Public key to send in the ``X-Colab-Ssh-Pubkey`` header.
301 +
302 + Returns:
303 + A connected ``websocket.WebSocket``.
304 +
305 + Raises:
306 + typer.Exit: On any handshake or connection failure (exit code 1), after
307 + printing an actionable message.
308 + """
309 + ws = websocket.WebSocket()
310 + try:
311 + ws.connect(url, header=[f"{_PUBKEY_HEADER}: {pubkey}"])
312 + return ws
313 + except websocket.WebSocketBadStatusException as e:
314 + status = getattr(e, "status_code", None)
315 + body = getattr(e, "resp_body", b"") or b""
316 + if isinstance(body, str):
317 + body = body.encode("utf-8", errors="replace")
318 + msg = _explain_handshake_failure(status, body)
319 + typer.echo(f"[colab] {msg}", err=True)
320 + raise typer.Exit(code=1)
321 + except (
322 + websocket.WebSocketAddressException,
323 + websocket.WebSocketTimeoutException,
324 + ConnectionRefusedError,
325 + OSError,
326 + ) as e:
327 + typer.echo(
328 + f"[colab] WebSocket connection failed: {e}. Check your network "
329 + "and that the runtime is healthy (`colab status`).",
330 + err=True,
331 + )
332 + raise typer.Exit(code=1)
333 +
334 +
335 +def _close_quietly(ws: websocket.WebSocket) -> None:
336 + """Closes a WebSocket, ignoring any error (best-effort teardown)."""
337 + try:
338 + ws.close()
339 + except Exception: # Best-effort close.
340 + pass
341 +
342 +
343 +_DATA_OPCODES = (websocket.ABNF.OPCODE_BINARY, websocket.ABNF.OPCODE_TEXT)
344 +
345 +
346 +def _bridge_proxy_mode(ws: websocket.WebSocket) -> int:
347 + """Bridges the WebSocket <-> stdin/stdout as an OpenSSH ProxyCommand.
348 +
349 + Args:
350 + ws: The connected WebSocket to bridge.
351 +
352 + Returns:
353 + 0 when either side closes.
354 + """
355 + stdin_fd = sys.stdin.buffer.fileno()
356 +
357 + def stdin_to_ws():
358 + try:
359 + while True:
360 + ready, _, _ = select.select([stdin_fd], [], [], None)
361 + if not ready:
362 + continue
363 + data = os.read(stdin_fd, 8192)
364 + if not data:
365 + break
366 + ws.send_binary(data)
367 + except (OSError, websocket.WebSocketException):
368 + pass
369 + finally:
370 + _close_quietly(ws)
371 +
372 + threading.Thread(target=stdin_to_ws, daemon=True).start()
373 +
374 + try:
375 + while True:
376 + opcode, frame = ws.recv_data(control_frame=True)
377 + if opcode == websocket.ABNF.OPCODE_CLOSE:
378 + break
379 + if opcode not in _DATA_OPCODES:
380 + continue
381 + if isinstance(frame, str):
382 + frame = frame.encode("utf-8")
383 + sys.stdout.buffer.write(frame)
384 + sys.stdout.buffer.flush()
385 + except (websocket.WebSocketException, OSError):
386 + pass
387 + finally:
388 + _close_quietly(ws)
389 + return 0
390 +
391 +
392 +def _proxy_command(session: SessionState, identity: Optional[str]) -> str:
393 + """Builds the OpenSSH ProxyCommand that bridges this session's WebSocket.
394 +
395 + Re-invoked by the interactive shell in ``--proxy-mode``. The session
396 + already exists by then, so only ``-s NAME`` (plus identity) is needed.
397 + """
398 + self_cmd = [
399 + sys.executable,
400 + "-m",
401 + "colab_cli.cli",
402 + "ssh",
403 + "--proxy-mode",
404 + "-s",
405 + session.name,
406 + ]
407 + if identity:
408 + self_cmd.extend(["--identity", identity])
409 + return shlex.join(self_cmd)
410 +
411 +
412 +def _ssh_base_args(proxy_command: str, identity: Optional[str]) -> list[str]:
413 + """Builds the shared ``ssh`` invocation (ProxyCommand + hardening)."""
414 + args = [
415 + "ssh",
416 + "-o",
417 + f"ProxyCommand={proxy_command}",
418 + "-o",
419 + "StrictHostKeyChecking=no",
420 + "-o",
421 + "UserKnownHostsFile=/dev/null",
422 + "-o",
423 + "LogLevel=ERROR",
424 + ]
425 + if identity:
426 + args.extend(["-i", os.path.expanduser(identity)])
427 + return args
428 +
429 +
430 +def _run_interactive_ssh(session: SessionState, identity: Optional[str]) -> int:
431 + """Spawns an interactive ``ssh`` that uses this CLI as its ProxyCommand.
432 +
433 + The subprocess connects to the abstract host ``colab-runtime``; its
434 + ProxyCommand re-invokes ``colab ssh --proxy-mode`` to bridge the WebSocket.
435 +
436 + A remote command ``cd``s into ``/content`` (Colab's working dir) and then
437 + execs the login shell, so the user lands where their notebooks/uploads live
438 + rather than in root's home. ``-t`` forces a PTY (required once a remote
439 + command is present) so the exec'd shell is interactive; a missing
440 + ``/content`` is tolerated (stderr suppressed, the shell still starts).
441 +
442 + Args:
443 + session: The session to connect to.
444 + identity: Optional private key path forwarded to ``ssh``.
445 +
446 + Returns:
447 + The exit code of the ``ssh`` subprocess.
448 + """
449 + ssh_args = _ssh_base_args(_proxy_command(session, identity), identity)
450 + ssh_args.append("-t")
451 + ssh_args.append(_SSH_HOST)
452 + ssh_args.append(
453 + f"cd {_DEFAULT_REMOTE_DIR} 2>/dev/null; exec ${{SHELL:-/bin/bash}} -l"
454 + )
455 + return subprocess.call(ssh_args)
456 +
457 +
458 +def _select_proxy_session(
459 + session: Optional[str], gpu: Optional[str], tpu: Optional[str]
460 +) -> tuple[SessionState, bool]:
461 + """Resolves (or creates) the session for ``--proxy-mode``.
462 +
463 + With ``-s NAME`` and no such session, creates it -- routing creation
464 + output to stderr so stdout stays the clean ssh byte stream -- so a
465 + ``~/.ssh/config`` host works on first connect. Otherwise resolves the
466 + named (or single active) session.
467 +
468 + Args:
469 + session: The requested session name, or None.
470 + gpu: GPU accelerator for an auto-created runtime.
471 + tpu: TPU accelerator for an auto-created runtime.
472 +
473 + Returns:
474 + A ``(session_state, created)`` pair.
475 + """
476 + if session and not _session_exists(session):
477 + with contextlib.redirect_stdout(sys.stderr):
478 + return _auto_create_session(gpu, tpu, name=session), True
479 + return _resolve_session(session), False
480 +
481 +
482 +def _select_interactive_session(
483 + session: Optional[str], gpu: Optional[str], tpu: Optional[str]
484 +) -> tuple[SessionState, bool]:
485 + """Resolves (or auto-creates) the session for an interactive shell.
486 +
487 + Bare ``colab ssh`` with an empty store auto-creates a runtime (like
488 + ``colab new``); otherwise the named or single active session is resolved.
489 +
490 + Args:
491 + session: The requested session name, or None.
492 + gpu: GPU accelerator for an auto-created runtime.
493 + tpu: TPU accelerator for an auto-created runtime.
494 +
495 + Returns:
496 + A ``(session_state, created)`` pair.
497 + """
498 + if not session and not _has_local_sessions():
499 + return _auto_create_session(gpu, tpu), True
500 + return _resolve_session(session), False
501 +
502 +
503 +def _warn_accelerator_ignored(
504 + gpu: Optional[str], tpu: Optional[str], created: bool
505 +) -> None:
506 + """Warns that ``--gpu/--tpu`` are no-ops when no runtime was created."""
507 + if (gpu or tpu) and not created:
508 + typer.echo(
509 + "[colab] --gpu/--tpu ignored: only applies to a created runtime.",
510 + err=True,
511 + )
512 +
513 +
514 +def _install_rm_signal_handlers(do_rm: Callable[[], None]) -> None:
515 + """Routes terminating signals to ``do_rm`` then a clean exit.
516 +
517 + OpenSSH ends a ProxyCommand on disconnect by sending SIGHUP (not just
518 + stdin EOF); Python's default SIGHUP action would terminate us WITHOUT
519 + running teardown, leaking the runtime and its keep-alive daemon. Convert
520 + SIGHUP/SIGTERM/SIGINT into ``do_rm`` + ``os._exit`` so ``--rm`` teardown
521 + always runs.
522 +
523 + Args:
524 + do_rm: Idempotent teardown callback to run before exiting.
525 + """
526 +
527 + def _on_signal(signum, frame):
528 + do_rm()
529 + os._exit(0)
530 +
531 + for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT):
532 + try:
533 + signal.signal(sig, _on_signal)
534 + except (ValueError, OSError):
535 + pass # e.g. not running in the main thread
536 +
537 +
538 +def _run_proxy_bridge(
539 + s: SessionState, identity: Optional[str], rm: bool
540 +) -> int:
541 + """Runs the ``--proxy-mode`` WebSocket-stdio bridge, honoring ``--rm``.
542 +
543 + Args:
544 + s: The session to bridge.
545 + identity: Optional private key path for the pubkey header.
546 + rm: If True, stop the session when the bridge closes or a terminating
547 + signal arrives.
548 +
549 + Returns:
550 + The bridge exit code.
551 + """
552 + # --rm teardown must be idempotent: it can fire from either a terminating
553 + # signal (how OpenSSH ends a ProxyCommand) or the `finally` clean-close
554 + # path. Output goes to stderr because our stdout is the ssh byte stream.
555 + done = {"stopped": False}
556 +
557 + def _do_rm() -> None:
558 + if rm and not done["stopped"]:
559 + done["stopped"] = True
560 + with contextlib.redirect_stdout(sys.stderr):
561 + _stop_session(s.name)
562 +
563 + if rm:
564 + _install_rm_signal_handlers(_do_rm)
565 +
566 + pubkey = _resolve_pubkey(identity)
567 + ws = _connect_websocket(_build_ws_url(s), pubkey)
568 + try:
569 + return _bridge_proxy_mode(ws)
570 + finally:
571 + _do_rm()
572 +
573 +
574 +def _run_interactive_shell(
575 + s: SessionState, identity: Optional[str], created: bool, rm: bool
576 +) -> int:
577 + """Runs the interactive ssh shell, honoring ``--rm`` on exit.
578 +
579 + Args:
580 + s: The session to connect to.
581 + identity: Optional private key path forwarded to ssh.
582 + created: Whether this command auto-created the runtime.
583 + rm: If True, stop an auto-created runtime on exit.
584 +
585 + Returns:
586 + The exit code of the ssh subprocess.
587 + """
588 + if rm and not created:
589 + typer.echo(
590 + "[colab] --rm ignored: only a runtime auto-created by `colab ssh` "
591 + "is removed on exit.",
592 + err=True,
593 + )
594 + try:
595 + return _run_interactive_ssh(s, identity)
596 + finally:
597 + if created and rm:
598 + _stop_session(s.name)
599 +
600 +
601 +def ssh(
602 + session: Annotated[
603 + Optional[str], typer.Option("-s", "--session", help="Session name")
604 + ] = None,
605 + proxy_mode: Annotated[
606 + bool,
607 + typer.Option(
608 + "--proxy-mode",
609 + help=(
610 + "Act as an OpenSSH ProxyCommand-compatible WebSocket-stdio "
611 + "bridge (reads stdin, writes stdout). Use in ~/.ssh/config "
612 + "as `ProxyCommand colab ssh --proxy-mode -s SESS`. All flags "
613 + "below also apply here."
614 + ),
615 + ),
616 + ] = False,
617 + identity: Annotated[
618 + Optional[str],
619 + typer.Option(
620 + "--identity",
621 + "-i",
622 + help=(
623 + "SSH private key whose public key is sent in the "
624 + "X-Colab-Ssh-Pubkey header (default: first of "
625 + "~/.ssh/id_ed25519, id_ecdsa)."
626 + ),
627 + ),
628 + ] = None,
629 + gpu: Annotated[
630 + Optional[str],
631 + typer.Option(
632 + "--gpu",
633 + help=(
634 + "GPU accelerator for a runtime created by this command "
635 + "(T4, L4, G4, H100, A100). Used when the session is "
636 + "auto-created."
637 + ),
638 + ),
639 + ] = None,
640 + tpu: Annotated[
641 + Optional[str],
642 + typer.Option(
643 + "--tpu",
644 + help=(
645 + "TPU accelerator for a runtime created by this command "
646 + "(v5e1, v6e1). Used when the session is auto-created."
647 + ),
648 + ),
649 + ] = None,
650 + rm: Annotated[
651 + bool,
652 + typer.Option(
653 + "--rm",
654 + help=(
655 + "Stop the runtime when the session ends. In interactive "
656 + "mode this applies only to a runtime `colab ssh` "
657 + "auto-created; in --proxy-mode it stops the bridged session "
658 + "on disconnect (ephemeral ~/.ssh/config host)."
659 + ),
660 + ),
661 + ] = False,
662 +):
663 + """Connect to a Colab runtime via SSH.
664 +
665 + Bare ``colab ssh`` uses your only active session, or auto-creates one (like
666 + ``colab new``) if you have none, and opens a shell in ``/content``. With
667 + --proxy-mode it is a ProxyCommand-compatible WebSocket-stdio bridge; every
668 + flag above still applies (``-s NAME`` creates the session if missing,
669 + ``--gpu/--tpu`` set its accelerator, ``--rm`` stops it on disconnect).
670 + """
671 + if proxy_mode:
672 + s, created = _select_proxy_session(session, gpu, tpu)
673 + _warn_accelerator_ignored(gpu, tpu, created)
674 + raise typer.Exit(code=_run_proxy_bridge(s, identity, rm))
675 +
676 + s, created = _select_interactive_session(session, gpu, tpu)
677 + _warn_accelerator_ignored(gpu, tpu, created)
678 + raise typer.Exit(code=_run_interactive_shell(s, identity, created, rm))
679 +
680 +
681 +def register(app: typer.Typer):
682 + app.command()(ssh)
tests/test_ssh.py new
+444
@@ -0,0 +1,444 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Tests for `colab ssh`.
16 +
17 +Covers WebSocket URL construction, pubkey resolution (--identity vs ~/.ssh
18 +scan, including its failure paths), per-status error-message mapping, the
19 +connect-failure path, the proxy-mode byte bridge, shell quoting, session
20 +resolution, --rm teardown error handling, and end-to-end dispatch (interactive
21 +vs --proxy-mode).
22 +"""
23 +
24 +import io
25 +import shlex
26 +import subprocess
27 +import sys
28 +from unittest.mock import MagicMock
29 +
30 +from colab_cli.cli import app
31 +from colab_cli.commands import ssh as ssh_module
32 +import pytest
33 +import typer
34 +from typer.testing import CliRunner
35 +import websocket
36 +
37 +runner = CliRunner()
38 +
39 +
40 +def _make_session(
41 + name: str = "s1",
42 + url: str = "https://abc-foo.colab.googleusercontent.com",
43 + token: str = "FAKE_TOKEN",
44 + endpoint: str = "abc123def",
45 +):
46 + s = MagicMock()
47 + s.name = name
48 + s.url = url
49 + s.token = token
50 + s.endpoint = endpoint
51 + return s
52 +
53 +
54 +# --- WS URL construction -----------------------------------------------------
55 +
56 +
57 +@pytest.mark.parametrize(
58 + ("url", "scheme"),
59 + [
60 + ("https://abc.colab.googleusercontent.com", "wss"),
61 + ("http://localhost:8080", "ws"),
62 + ],
63 + ids=["https->wss", "http->ws"],
64 +)
65 +def test_build_ws_url_scheme(url, scheme):
66 + s = _make_session(url=url)
67 + out = ssh_module._build_ws_url(s)
68 + netloc = url.split("://", 1)[1]
69 + assert out.startswith(f"{scheme}://{netloc}/colab/ssh")
70 + assert "colab-runtime-proxy-token=FAKE_TOKEN" in out
71 +
72 +
73 +# --- Pubkey resolution -------------------------------------------------------
74 +
75 +
76 +def test_resolve_pubkey_with_identity_calls_ssh_keygen(mocker, tmp_path):
77 + key = tmp_path / "id_test"
78 + key.write_text("(fake private key)")
79 + fake_pub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDfake user@host"
80 + mock_run = mocker.patch(
81 + "subprocess.run",
82 + return_value=MagicMock(stdout=fake_pub + "\n", returncode=0),
83 + )
84 + out = ssh_module._resolve_pubkey(str(key))
85 + assert out == fake_pub
86 + args, _ = mock_run.call_args
87 + assert args[0][:3] == ["ssh-keygen", "-y", "-f"]
88 + assert args[0][3] == str(key)
89 +
90 +
91 +def test_resolve_pubkey_missing_identity_exits(tmp_path):
92 + missing = tmp_path / "no-such-key"
93 + with pytest.raises(typer.Exit) as exc_info:
94 + ssh_module._resolve_pubkey(str(missing))
95 + assert exc_info.value.exit_code == 2
96 +
97 +
98 +@pytest.mark.parametrize(
99 + ("run_side_effect", "run_return"),
100 + [
101 + (subprocess.CalledProcessError(1, ["ssh-keygen"]), None),
102 + (FileNotFoundError("ssh-keygen not installed"), None),
103 + (None, MagicMock(stdout=" \n", returncode=0)),
104 + ],
105 + ids=["ssh-keygen-error", "ssh-keygen-missing", "empty-output"],
106 +)
107 +def test_resolve_pubkey_identity_derivation_failures_exit_2(
108 + mocker, tmp_path, run_side_effect, run_return
109 +):
110 + """--identity given but key derivation fails -> clean exit 2 (no traceback).
111 +
112 + The empty-output case is a regression guard: it used to raise an uncaught
113 + RuntimeError instead of a `typer.Exit`.
114 + """
115 + key = tmp_path / "id_test"
116 + key.write_text("(fake private key)")
117 + if run_side_effect is not None:
118 + mocker.patch("subprocess.run", side_effect=run_side_effect)
119 + else:
120 + mocker.patch("subprocess.run", return_value=run_return)
121 + with pytest.raises(typer.Exit) as exc_info:
122 + ssh_module._resolve_pubkey(str(key))
123 + assert exc_info.value.exit_code == 2
124 +
125 +
126 +@pytest.mark.parametrize(
127 + ("present", "expect_found"),
128 + [
129 + ("id_ed25519.pub", True),
130 + ("id_ecdsa.pub", True),
131 + ("id_rsa.pub", False), # RSA is server-rejected -> not auto-selected
132 + (None, False), # no keys at all
133 + ],
134 + ids=["ed25519", "ecdsa", "rsa-not-selected", "no-keys"],
135 +)
136 +def test_resolve_pubkey_default_scan_key_order(
137 + monkeypatch, tmp_path, present, expect_found
138 +):
139 + fake_home = tmp_path / "home"
140 + ssh_dir = fake_home / ".ssh"
141 + ssh_dir.mkdir(parents=True)
142 + content = ""
143 + if present:
144 + content = f"ssh-key-content-for-{present}\n"
145 + (ssh_dir / present).write_text(content)
146 + monkeypatch.setattr(
147 + "os.path.expanduser", lambda p: p.replace("~", str(fake_home))
148 + )
149 + if expect_found:
150 + assert ssh_module._resolve_pubkey(None) == content.strip()
151 + else:
152 + with pytest.raises(typer.Exit) as exc_info:
153 + ssh_module._resolve_pubkey(None)
154 + assert exc_info.value.exit_code == 2
155 +
156 +
157 +# --- Per-failure-mode error mapping -----------------------------------------
158 +
159 +
160 +@pytest.mark.parametrize(
161 + ("status", "body", "must_contain"),
162 + [
163 + (400, b"missing pubkey", "missing pubkey header"),
164 + (400, b"unsupported key type", "unsupported key type"),
165 + (400, b"invalid pubkey: bad base64", "Re-check your key"),
166 + (401, b"", "token is invalid"),
167 + (403, b"", "Forbidden"),
168 + (404, b"", "Endpoint not found"),
169 + (429, b'{"error":"already-active-session"}', "Already-active SSH"),
170 + (502, b"sshd unreachable", "Bad gateway"),
171 + (503, b"", "WebSocket upgrade rejected (HTTP 503)"),
172 + (None, b"", "WebSocket upgrade failed without an HTTP status"),
173 + ],
174 +)
175 +def test_explain_handshake_failure_mapping(status, body, must_contain):
176 + out = ssh_module._explain_handshake_failure(status, body)
177 + assert must_contain in out
178 +
179 +
180 +def test_explain_handshake_failure_decodes_str_body():
181 + """A str resp_body is tolerated by the caller's normalization."""
182 + # _connect_websocket encodes str bodies before calling this; assert the
183 + # decode path here handles bytes with invalid utf-8 too.
184 + out = ssh_module._explain_handshake_failure(400, b"\xff\xfe bad")
185 + assert "HTTP 400" in out
186 +
187 +
188 +# --- connect failure (non-HTTP-status network errors) ------------------------
189 +
190 +
191 +@pytest.mark.parametrize(
192 + "exc",
193 + [
194 + websocket.WebSocketAddressException("bad address"),
195 + websocket.WebSocketTimeoutException("timed out"),
196 + ConnectionRefusedError("connection refused"),
197 + OSError("network is down"),
198 + ],
199 + ids=["address", "timeout", "refused", "oserror"],
200 +)
201 +def test_connect_websocket_network_failure_exits_1(mocker, capsys, exc):
202 + mocker.patch.object(websocket.WebSocket, "connect", side_effect=exc)
203 + with pytest.raises(typer.Exit) as exc_info:
204 + ssh_module._connect_websocket("wss://host/colab/ssh?x=1", "pk")
205 + assert exc_info.value.exit_code == 1
206 + assert "WebSocket connection failed" in capsys.readouterr().err
207 +
208 +
209 +# --- proxy-mode byte bridge (ws <-> stdout) ---------------------------------
210 +
211 +
212 +def test_bridge_proxy_mode_pumps_ws_to_stdout(mocker):
213 + """Binary + text frames reach stdout; a non-data opcode is ignored; CLOSE
214 + ends the loop; the socket is closed and 0 is returned."""
215 + # The stdin->ws pump reads a real fd in a thread; stub the thread out so the
216 + # test deterministically exercises only the ws->stdout direction.
217 + mocker.patch("threading.Thread")
218 + mocker.patch("sys.stdin")
219 + fake_stdout = MagicMock()
220 + fake_stdout.buffer = io.BytesIO()
221 + mocker.patch("sys.stdout", fake_stdout)
222 +
223 + abnf = websocket.ABNF
224 + ws = MagicMock()
225 + ws.recv_data.side_effect = [
226 + (abnf.OPCODE_BINARY, b"hello "),
227 + (abnf.OPCODE_TEXT, "world"),
228 + (abnf.OPCODE_PING, b""), # ignored (not BINARY/TEXT/CLOSE)
229 + (abnf.OPCODE_CLOSE, b""),
230 + ]
231 +
232 + rc = ssh_module._bridge_proxy_mode(ws)
233 + assert rc == 0
234 + assert fake_stdout.buffer.getvalue() == b"hello world"
235 + ws.close.assert_called()
236 +
237 +
238 +# --- ProxyCommand shell quoting ---------------------------------------------
239 +
240 +
241 +@pytest.mark.parametrize(
242 + "name",
243 + [
244 + "simple",
245 + "with space",
246 + "a'b",
247 + "a@b.c:d=e,f",
248 + "$(touch /tmp/pwned)",
249 + "a;rm -rf /",
250 + "ünïcode",
251 + ],
252 + ids=[
253 + "word",
254 + "space",
255 + "single-quote",
256 + "safe-punct",
257 + "cmd-substitution",
258 + "semicolon",
259 + "non-ascii",
260 + ],
261 +)
262 +@pytest.mark.parametrize(
263 + "identity", [None, "/k/id ed25519"], ids=["no-identity", "identity-space"]
264 +)
265 +def test_proxy_command_round_trips_through_the_shell(name, identity):
266 + """The ProxyCommand string must re-parse into the exact argv.
267 +
268 + `ssh` hands the ProxyCommand to /bin/sh, so every argument has to survive
269 + word-splitting verbatim -- a hostile session name must arrive as one
270 + literal argument, never as a new word or a substitution.
271 + """
272 + cmd = ssh_module._proxy_command(_make_session(name=name), identity)
273 + argv = shlex.split(cmd)
274 +
275 + assert argv[:7] == [
276 + sys.executable,
277 + "-m",
278 + "colab_cli.cli",
279 + "ssh",
280 + "--proxy-mode",
281 + "-s",
282 + name,
283 + ]
284 + if identity:
285 + assert argv[7:] == ["--identity", identity]
286 + else:
287 + assert len(argv) == 7
288 +
289 +
290 +# --- session resolution ------------------------------------------------------
291 +
292 +
293 +@pytest.mark.parametrize("found", [True, False], ids=["existing", "missing"])
294 +def test_resolve_session(mock_common_state, found):
295 + sess = _make_session(name="x")
296 + mock_common_state.resolve_session.return_value = "x"
297 + mock_common_state.store.get.return_value = sess if found else None
298 + if found:
299 + assert ssh_module._resolve_session("x") is sess
300 + mock_common_state.store.get.assert_called_with("x")
301 + else:
302 + with pytest.raises(typer.Exit) as exc_info:
303 + ssh_module._resolve_session("x")
304 + assert exc_info.value.exit_code == 2
305 +
306 +
307 +# --- --rm teardown error handling -------------------------------------------
308 +
309 +
310 +@pytest.mark.parametrize(
311 + ("exc", "expect_raises"),
312 + [
313 + (RuntimeError("boom"), False),
314 + (typer.Exit(3), True),
315 + ],
316 + ids=["generic-swallowed", "typer-exit-reraised"],
317 +)
318 +def test_stop_session_error_handling(mocker, capsys, exc, expect_raises):
319 + """A failed `colab stop` during --rm must not crash the shell, except that
320 + a `typer.Exit` (a deliberate exit) is allowed to propagate."""
321 + mocker.patch("colab_cli.commands.session.stop", side_effect=exc)
322 + if expect_raises:
323 + with pytest.raises(typer.Exit):
324 + ssh_module._stop_session("s1")
325 + else:
326 + ssh_module._stop_session("s1") # must not raise
327 + assert "failed to stop 's1'" in capsys.readouterr().err
328 +
329 +
330 +# --- end-to-end CLI dispatch ------------------------------------------------
331 +
332 +
333 +def test_ssh_proxy_mode_calls_websocket(mock_common_state, mocker):
334 + """--proxy-mode calls _connect_websocket + _bridge_proxy_mode (no ssh)."""
335 + sess = _make_session()
336 + mock_common_state.resolve_session.return_value = "s1"
337 + mock_common_state.store.get.return_value = sess
338 +
339 + fake_pub = "ssh-ed25519 AAAAfakefakefake user@host"
340 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value=fake_pub)
341 +
342 + fake_ws = MagicMock()
343 + connect = mocker.patch.object(
344 + ssh_module, "_connect_websocket", return_value=fake_ws
345 + )
346 + bridge = mocker.patch.object(
347 + ssh_module, "_bridge_proxy_mode", return_value=0
348 + )
349 + ssh_subprocess = mocker.patch.object(ssh_module, "_run_interactive_ssh")
350 +
351 + result = runner.invoke(app, ["ssh", "--proxy-mode", "-s", "s1"])
352 + assert result.exit_code == 0
353 + connect.assert_called_once()
354 + args, _ = connect.call_args
355 + assert args[0].startswith(
356 + "wss://abc-foo.colab.googleusercontent.com/colab/ssh"
357 + )
358 + assert args[1] == fake_pub
359 + bridge.assert_called_once_with(fake_ws)
360 + ssh_subprocess.assert_not_called()
361 +
362 +
363 +def test_ssh_interactive_mode_calls_ssh_subprocess(mock_common_state, mocker):
364 + """Bare `colab ssh -s S` spawns ssh subprocess; does NOT bridge directly."""
365 + sess = _make_session()
366 + mock_common_state.resolve_session.return_value = "s1"
367 + mock_common_state.store.get.return_value = sess
368 +
369 + mocker.patch.object(
370 + ssh_module, "_resolve_pubkey", return_value="ssh-ed25519 AAAAfake u@h"
371 + )
372 + interactive = mocker.patch.object(
373 + ssh_module, "_run_interactive_ssh", return_value=0
374 + )
375 + bridge = mocker.patch.object(ssh_module, "_bridge_proxy_mode")
376 +
377 + result = runner.invoke(app, ["ssh", "-s", "s1"])
378 + assert result.exit_code == 0
379 + interactive.assert_called_once_with(sess, None)
380 + bridge.assert_not_called()
381 +
382 +
383 +def test_ssh_pubkey_passes_through_verbatim(mock_common_state, mocker):
384 + """The bytes from _resolve_pubkey reach _connect_websocket unchanged.
385 +
386 + Adversarial: confirms there is no intermediate substitution, prefix,
387 + suffix, or constant - the pubkey arg seen by _connect_websocket is exactly
388 + the bytes _resolve_pubkey returned.
389 + """
390 + sess = _make_session()
391 + mock_common_state.resolve_session.return_value = "s1"
392 + mock_common_state.store.get.return_value = sess
393 +
394 + payload = "ssh-ed25519 AAAAUNIQUEMARKER1234567890 user@host"
395 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value=payload)
396 +
397 + captured = {}
398 +
399 + def fake_connect(url, pubkey):
400 + captured["pubkey"] = pubkey
401 + captured["url"] = url
402 + return MagicMock()
403 +
404 + mocker.patch.object(
405 + ssh_module, "_connect_websocket", side_effect=fake_connect
406 + )
407 + mocker.patch.object(ssh_module, "_bridge_proxy_mode", return_value=0)
408 +
409 + runner.invoke(app, ["ssh", "--proxy-mode", "-s", "s1"])
410 + assert captured["pubkey"] == payload # verbatim
411 +
412 +
413 +@pytest.mark.parametrize(
414 + "resp_body",
415 + [b"unsupported key type", "unsupported key type"],
416 + ids=["bytes-body", "str-body"],
417 +)
418 +def test_ssh_handshake_400_emits_actionable_message(
419 + mock_common_state, mocker, resp_body
420 +):
421 + """A 400 'unsupported key type' surfaces the keygen remediation hint.
422 +
423 + Parametrized over a bytes vs str resp_body so the str-normalization path in
424 + _connect_websocket is exercised too.
425 + """
426 + sess = _make_session()
427 + mock_common_state.resolve_session.return_value = "s1"
428 + mock_common_state.store.get.return_value = sess
429 + mocker.patch.object(
430 + ssh_module, "_resolve_pubkey", return_value="ssh-rsa AAAAfake u@h"
431 + )
432 +
433 + err = websocket.WebSocketBadStatusException(
434 + "Handshake status 400 Bad Request", 400
435 + )
436 + err.status_code = 400
437 + err.resp_body = resp_body
438 + mocker.patch.object(websocket.WebSocket, "connect", side_effect=err)
439 + mocker.patch.object(ssh_module, "_bridge_proxy_mode", return_value=0)
440 +
441 + result = runner.invoke(app, ["ssh", "--proxy-mode", "-s", "s1"])
442 + assert result.exit_code == 1
443 + assert "unsupported key type" in result.stderr
444 + assert "ssh-keygen -t ed25519" in result.stderr
tests/test_ssh_autocreate.py new
+313
@@ -0,0 +1,313 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Tests for `colab ssh` auto-create + --proxy-mode flag behavior.
16 +
17 +Bare `colab ssh` (no -s): ssh into the single existing session, auto-create one
18 +when there are none, or error when there are several. `--gpu/--tpu` pass through
19 +to an auto-created runtime (and are ignored otherwise); `--rm` stops a runtime
20 +this command created. In --proxy-mode every flag still applies: `-s NAME`
21 +creates the session if missing, and --rm stops the bridged session on
22 +disconnect (installing SIGHUP/SIGTERM/SIGINT handlers so teardown survives the
23 +way OpenSSH ends a ProxyCommand).
24 +"""
25 +
26 +from unittest.mock import MagicMock
27 +
28 +from colab_cli.cli import app
29 +from colab_cli.commands import ssh as ssh_module
30 +import pytest
31 +from typer.testing import CliRunner
32 +import typer
33 +
34 +runner = CliRunner()
35 +
36 +
37 +def _make_session(
38 + name: str = "auto1",
39 + url: str = "https://abc.colab.googleusercontent.com",
40 + token: str = "TOK",
41 + endpoint: str = "ep1",
42 +):
43 + s = MagicMock()
44 + s.name = name
45 + s.url = url
46 + s.token = token
47 + s.endpoint = endpoint
48 + return s
49 +
50 +
51 +def _patch_interactive(mocker):
52 + mocker.patch.object(
53 + ssh_module, "_resolve_pubkey", return_value="ssh-ed25519 AAAA u@h"
54 + )
55 + return mocker.patch.object(
56 + ssh_module, "_run_interactive_ssh", return_value=0
57 + )
58 +
59 +
60 +def _patch_proxy(mocker):
61 + mocker.patch.object(
62 + ssh_module, "_resolve_pubkey", return_value="ssh-ed25519 AAAA u@h"
63 + )
64 + mocker.patch.object(
65 + ssh_module, "_connect_websocket", return_value=MagicMock()
66 + )
67 + mocker.patch.object(ssh_module, "_bridge_proxy_mode", return_value=0)
68 +
69 +
70 +# --- bare `colab ssh`: create / reuse / ambiguous ---------------------------
71 +
72 +
73 +@pytest.mark.parametrize(
74 + ("sessions", "resolve_raises", "gpu", "expect_create", "expect_ok"),
75 + [
76 + ({}, False, None, True, True),
77 + ({}, False, "T4", True, True),
78 + ({"only": 1}, False, None, False, True),
79 + ({"a": 1, "b": 1}, True, None, False, False),
80 + ],
81 + ids=["zero-creates", "zero-creates-gpu", "one-reuses", "many-errors"],
82 +)
83 +def test_bare_ssh_session_resolution(
84 + mock_common_state,
85 + mocker,
86 + sessions,
87 + resolve_raises,
88 + gpu,
89 + expect_create,
90 + expect_ok,
91 +):
92 + mock_common_state.store.list.return_value = sessions
93 + sess = _make_session()
94 + mock_common_state.store.get.return_value = sess
95 + if resolve_raises:
96 + mock_common_state.resolve_session.side_effect = typer.Exit(1)
97 + else:
98 + mock_common_state.resolve_session.return_value = "only"
99 +
100 + new = mocker.patch("colab_cli.commands.session.new")
101 + interactive = _patch_interactive(mocker)
102 +
103 + args = ["ssh"] + (["--gpu", gpu] if gpu else [])
104 + result = runner.invoke(app, args)
105 +
106 + if expect_ok:
107 + assert result.exit_code == 0
108 + interactive.assert_called_once_with(sess, None)
109 + else:
110 + assert result.exit_code != 0
111 + interactive.assert_not_called()
112 +
113 + if expect_create:
114 + new.assert_called_once()
115 + assert new.call_args.kwargs.get("gpu") == gpu
116 + assert new.call_args.kwargs.get("tpu") is None
117 + else:
118 + new.assert_not_called()
119 +
120 +
121 +# --- --proxy-mode -s NAME: create-if-missing / reuse ------------------------
122 +
123 +
124 +@pytest.mark.parametrize(
125 + ("exists", "gpu", "expect_new"),
126 + [
127 + (False, None, True),
128 + (False, "T4", True),
129 + (True, None, False),
130 + ],
131 + ids=["missing-creates", "missing-creates-gpu", "existing-reuses"],
132 +)
133 +def test_proxy_mode_create_or_reuse(
134 + mock_common_state, mocker, exists, gpu, expect_new
135 +):
136 + created = _make_session("colab")
137 + if exists:
138 + mock_common_state.store.get.return_value = created
139 + mock_common_state.resolve_session.return_value = "colab"
140 + else:
141 + mock_common_state.store.get.return_value = None
142 +
143 + def after_new(*a, **k):
144 + mock_common_state.store.get.return_value = created
145 +
146 + new = mocker.patch("colab_cli.commands.session.new", side_effect=after_new)
147 + _patch_proxy(mocker)
148 +
149 + args = ["ssh", "--proxy-mode", "-s", "colab"]
150 + if gpu:
151 + args += ["--gpu", gpu]
152 + result = runner.invoke(app, args)
153 + assert result.exit_code == 0
154 +
155 + if expect_new:
156 + new.assert_called_once()
157 + assert new.call_args.kwargs.get("session") == "colab"
158 + assert new.call_args.kwargs.get("gpu") == gpu
159 + else:
160 + new.assert_not_called()
161 +
162 +
163 +def test_proxy_mode_no_session_does_not_autocreate(mock_common_state, mocker):
164 + """--proxy-mode with no -s does not auto-create (nothing to name)."""
165 + mock_common_state.store.list.return_value = {}
166 + mock_common_state.resolve_session.side_effect = typer.Exit(2)
167 + new = mocker.patch("colab_cli.commands.session.new")
168 + connect = mocker.patch.object(ssh_module, "_connect_websocket")
169 + mocker.patch.object(
170 + ssh_module, "_resolve_pubkey", return_value="ssh-ed25519 AAAA u@h"
171 + )
172 +
173 + result = runner.invoke(app, ["ssh", "--proxy-mode"])
174 + assert result.exit_code != 0
175 + new.assert_not_called()
176 + connect.assert_not_called()
177 +
178 +
179 +# --- --gpu/--tpu ignored when not creating ----------------------------------
180 +
181 +
182 +@pytest.mark.parametrize(
183 + ("extra_args", "expect_msg"),
184 + [
185 + (
186 + ["--proxy-mode", "-s", "colab", "--gpu", "T4"],
187 + "only applies to a created runtime",
188 + ),
189 + (
190 + ["-s", "colab", "--tpu", "v5e1"],
191 + "only applies to a created runtime",
192 + ),
193 + ],
194 + ids=["proxy-existing", "interactive-reuse"],
195 +)
196 +def test_gpu_tpu_ignored_when_not_creating(
197 + mock_common_state, mocker, extra_args, expect_msg
198 +):
199 + sess = _make_session("colab")
200 + mock_common_state.store.get.return_value = sess
201 + mock_common_state.store.list.return_value = {"colab": sess}
202 + mock_common_state.resolve_session.return_value = "colab"
203 + _patch_proxy(mocker)
204 + mocker.patch.object(ssh_module, "_run_interactive_ssh", return_value=0)
205 +
206 + result = runner.invoke(app, ["ssh", *extra_args])
207 + assert result.exit_code == 0
208 + assert expect_msg in result.stderr
209 +
210 +
211 +# --- --rm teardown ----------------------------------------------------------
212 +
213 +
214 +@pytest.mark.parametrize(
215 + ("rm", "expect_stop"),
216 + [(True, True), (False, False)],
217 + ids=["rm-stops", "no-rm-keeps"],
218 +)
219 +def test_proxy_mode_rm_teardown(mock_common_state, mocker, rm, expect_stop):
220 + created = _make_session("colab-ephem")
221 + mock_common_state.store.get.return_value = None
222 +
223 + def after_new(*a, **k):
224 + mock_common_state.store.get.return_value = created
225 +
226 + mocker.patch("colab_cli.commands.session.new", side_effect=after_new)
227 + stop = mocker.patch("colab_cli.commands.session.stop")
228 + mocker.patch("signal.signal") # don't install real handlers during tests
229 + _patch_proxy(mocker)
230 +
231 + args = ["ssh", "--proxy-mode", "-s", "colab-ephem"]
232 + if rm:
233 + args.append("--rm")
234 + result = runner.invoke(app, args)
235 + assert result.exit_code == 0
236 + if expect_stop:
237 + stop.assert_called_once_with(session="colab-ephem")
238 + else:
239 + stop.assert_not_called()
240 +
241 +
242 +@pytest.mark.parametrize(
243 + ("has_existing", "expect_stop"),
244 + [(False, True), (True, False)],
245 + ids=["autocreated-stops", "reused-keeps"],
246 +)
247 +def test_interactive_rm_teardown(
248 + mock_common_state, mocker, has_existing, expect_stop
249 +):
250 + """Interactive --rm stops only a runtime `colab ssh` auto-created."""
251 + if has_existing:
252 + sess = _make_session("only")
253 + mock_common_state.store.list.return_value = {"only": sess}
254 + mock_common_state.resolve_session.return_value = "only"
255 + else:
256 + sess = _make_session("auto-rm")
257 + mock_common_state.store.list.return_value = {}
258 + mock_common_state.store.get.return_value = sess
259 +
260 + mocker.patch("colab_cli.commands.session.new")
261 + stop = mocker.patch("colab_cli.commands.session.stop")
262 + _patch_interactive(mocker)
263 +
264 + result = runner.invoke(app, ["ssh", "--rm"])
265 + assert result.exit_code == 0
266 + if expect_stop:
267 + stop.assert_called_once_with(session="auto-rm")
268 + else:
269 + stop.assert_not_called()
270 +
271 +
272 +# --- signal-handler installation (proxy-mode --rm) --------------------------
273 +
274 +
275 +@pytest.mark.parametrize(
276 + ("rm", "expect_installed"),
277 + [(True, True), (False, False)],
278 + ids=["rm-installs", "no-rm-none"],
279 +)
280 +def test_proxy_mode_signal_handler_installation(
281 + mock_common_state, mocker, rm, expect_installed
282 +):
283 + """--rm installs SIGHUP/SIGTERM/SIGINT handlers so teardown runs when
284 + OpenSSH HUPs the ProxyCommand on disconnect; without --rm, none."""
285 + import signal as _signal
286 +
287 + mock_common_state.store.get.return_value = _make_session("colab")
288 + mock_common_state.resolve_session.return_value = "colab"
289 + sigmock = mocker.patch("signal.signal")
290 + mocker.patch("colab_cli.commands.session.stop")
291 + _patch_proxy(mocker)
292 +
293 + args = ["ssh", "--proxy-mode", "-s", "colab"]
294 + if rm:
295 + args.append("--rm")
296 + result = runner.invoke(app, args)
297 + assert result.exit_code == 0
298 + if expect_installed:
299 + registered = {c.args[0] for c in sigmock.call_args_list}
300 + assert {_signal.SIGHUP, _signal.SIGTERM, _signal.SIGINT} <= registered
301 + else:
302 + sigmock.assert_not_called()
303 +
304 +
305 +# --- help --------------------------------------------------------------------
306 +
307 +
308 +def test_ssh_help_advertises_autocreate_flags():
309 + """`colab ssh --help` advertises --rm, --gpu, and --tpu."""
310 + result = runner.invoke(app, ["ssh", "--help"])
311 + assert result.exit_code == 0
312 + for flag in ("--rm", "--gpu", "--tpu"):
313 + assert flag in result.output
tests/test_ssh_lifecycle.py new
+284
@@ -0,0 +1,284 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Lifecycle & dispatch guarantees for `colab ssh`.
16 +
17 +Complements the unit suite (test_ssh.py), the autocreate/flag suite
18 +(test_ssh_autocreate.py), and the real-wire suite (test_ssh_wire_contract.py)
19 +by pinning the command's *lifecycle* contracts -- the same class of guarantees
20 +test_run.py enforces for `colab run`:
21 +
22 +* A. --rm teardown runs even when the bridge/shell raises (try/finally).
23 +* B. the ssh/bridge exit code propagates to the process exit code.
24 +* C. in --proxy-mode, create + --rm chatter stays on stderr so stdout remains
25 + the clean ssh byte stream (a dropped redirect would corrupt every real
26 + connection yet pass the mocked suite -- cf. test_ssh_wire_contract.py).
27 +* D. auto-create failure aborts before any WebSocket connect (don't burn a VM
28 + then fail).
29 +* E. --gpu/--tpu are forwarded verbatim to `colab new` (which owns precedence).
30 +* F. --rm teardown is idempotent across the signal path and the finally path.
31 +* G. --proxy-mode --rm stops a *reused* session too, not just an auto-created
32 + one.
33 +"""
34 +
35 +from unittest.mock import MagicMock
36 +
37 +from colab_cli.cli import app
38 +from colab_cli.commands import ssh as ssh_module
39 +import pytest
40 +import typer
41 +from typer.testing import CliRunner
42 +
43 +runner = CliRunner()
44 +
45 +
46 +def _make_session(
47 + name: str = "s1",
48 + url: str = "https://abc.colab.googleusercontent.com",
49 + token: str = "TOK",
50 + endpoint: str = "ep",
51 +):
52 + s = MagicMock()
53 + s.name = name
54 + s.url = url
55 + s.token = token
56 + s.endpoint = endpoint
57 + return s
58 +
59 +
60 +def _patch_proxy(mocker):
61 + """Stub the proxy-mode I/O seams (pubkey, connect, bridge)."""
62 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
63 + mocker.patch.object(
64 + ssh_module, "_connect_websocket", return_value=MagicMock()
65 + )
66 + mocker.patch.object(ssh_module, "_bridge_proxy_mode", return_value=0)
67 +
68 +
69 +# --- A. --rm teardown survives an exception (try/finally guarantee) ----------
70 +
71 +
72 +def test_proxy_mode_rm_stops_even_if_bridge_raises(mock_common_state, mocker):
73 + """If _bridge_proxy_mode raises, the --rm stop still runs (finally)."""
74 + sess = _make_session("colab-ephem")
75 + mock_common_state.store.get.return_value = sess
76 + mock_common_state.resolve_session.return_value = "colab-ephem"
77 + mocker.patch("signal.signal")
78 + stop = mocker.patch("colab_cli.commands.session.stop")
79 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
80 + mocker.patch.object(
81 + ssh_module, "_connect_websocket", return_value=MagicMock()
82 + )
83 + mocker.patch.object(
84 + ssh_module, "_bridge_proxy_mode", side_effect=RuntimeError("ws died")
85 + )
86 +
87 + result = runner.invoke(
88 + app, ["ssh", "--proxy-mode", "-s", "colab-ephem", "--rm"]
89 + )
90 + assert result.exit_code != 0
91 + stop.assert_called_once_with(session="colab-ephem")
92 +
93 +
94 +def test_interactive_rm_stops_even_if_shell_raises(mock_common_state, mocker):
95 + """If _run_interactive_ssh raises, an auto-created runtime is still
96 + stopped (finally)."""
97 + sess = _make_session("auto-rm")
98 + mock_common_state.store.list.return_value = {} # empty -> auto-create
99 + mock_common_state.store.get.return_value = sess
100 + mocker.patch("colab_cli.commands.session.new")
101 + stop = mocker.patch("colab_cli.commands.session.stop")
102 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
103 + mocker.patch.object(
104 + ssh_module, "_run_interactive_ssh", side_effect=RuntimeError("boom")
105 + )
106 +
107 + result = runner.invoke(app, ["ssh", "--rm"])
108 + assert result.exit_code != 0
109 + stop.assert_called_once_with(session="auto-rm")
110 +
111 +
112 +# --- B. exit-code propagation ------------------------------------------------
113 +
114 +
115 +@pytest.mark.parametrize("code", [0, 1, 255], ids=["ok", "err", "ssh-fail"])
116 +def test_interactive_exit_code_propagates(mock_common_state, mocker, code):
117 + """The interactive ssh subprocess's exit code becomes the CLI exit code."""
118 + sess = _make_session("s1")
119 + mock_common_state.store.get.return_value = sess
120 + mock_common_state.resolve_session.return_value = "s1"
121 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
122 + mocker.patch.object(ssh_module, "_run_interactive_ssh", return_value=code)
123 +
124 + result = runner.invoke(app, ["ssh", "-s", "s1"])
125 + assert result.exit_code == code
126 +
127 +
128 +@pytest.mark.parametrize("code", [0, 42], ids=["ok", "nonzero"])
129 +def test_proxy_mode_exit_code_propagates(mock_common_state, mocker, code):
130 + """The proxy-mode bridge's return code becomes the CLI exit code."""
131 + sess = _make_session("s1")
132 + mock_common_state.store.get.return_value = sess
133 + mock_common_state.resolve_session.return_value = "s1"
134 + _patch_proxy(mocker)
135 + mocker.patch.object(ssh_module, "_bridge_proxy_mode", return_value=code)
136 +
137 + result = runner.invoke(app, ["ssh", "--proxy-mode", "-s", "s1"])
138 + assert result.exit_code == code
139 +
140 +
141 +# --- C. --proxy-mode keeps stdout clean (byte-stream integrity) --------------
142 +
143 +
144 +def test_proxy_select_routes_create_output_to_stderr(mocker, capsys):
145 + """Auto-create chatter must land on stderr, never stdout -- in
146 + --proxy-mode stdout IS the ssh byte stream."""
147 + sess = _make_session("newname")
148 + mocker.patch.object(ssh_module, "_session_exists", return_value=False)
149 + mocker.patch.object(ssh_module, "_resolve_session", return_value=sess)
150 + mocker.patch("colab_cli.commands.session.new")
151 +
152 + s, created = ssh_module._select_proxy_session("newname", "T4", None)
153 + assert created is True and s is sess
154 +
155 + captured = capsys.readouterr()
156 + assert "Creating runtime" in captured.err
157 + assert "Creating runtime" not in captured.out
158 +
159 +
160 +def test_proxy_bridge_routes_rm_output_to_stderr(mocker, capsys):
161 + """--rm stop chatter must land on stderr, never stdout."""
162 + sess = _make_session("colab")
163 + mocker.patch("signal.signal")
164 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
165 + mocker.patch.object(
166 + ssh_module, "_connect_websocket", return_value=MagicMock()
167 + )
168 + mocker.patch.object(ssh_module, "_bridge_proxy_mode", return_value=0)
169 +
170 + def stop_echo(session=None):
171 + typer.echo("STOP-MARKER")
172 +
173 + mocker.patch("colab_cli.commands.session.stop", side_effect=stop_echo)
174 +
175 + rc = ssh_module._run_proxy_bridge(sess, None, rm=True)
176 + assert rc == 0
177 +
178 + captured = capsys.readouterr()
179 + assert "STOP-MARKER" in captured.err
180 + assert "STOP-MARKER" not in captured.out
181 +
182 +
183 +# --- D. auto-create failure aborts before any WebSocket connect --------------
184 +
185 +
186 +def test_proxy_mode_autocreate_failure_skips_connect(mock_common_state, mocker):
187 + """A failed `colab new` in --proxy-mode must not proceed to connect."""
188 + mock_common_state.store.get.return_value = None # session missing
189 + mocker.patch("colab_cli.commands.session.new", side_effect=typer.Exit(1))
190 + connect = mocker.patch.object(ssh_module, "_connect_websocket")
191 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
192 +
193 + result = runner.invoke(app, ["ssh", "--proxy-mode", "-s", "new"])
194 + assert result.exit_code != 0
195 + connect.assert_not_called()
196 +
197 +
198 +def test_interactive_autocreate_failure_skips_ssh(mock_common_state, mocker):
199 + """A failed `colab new` in interactive mode must not spawn ssh."""
200 + mock_common_state.store.list.return_value = {} # empty -> auto-create
201 + mocker.patch("colab_cli.commands.session.new", side_effect=typer.Exit(1))
202 + interactive = mocker.patch.object(ssh_module, "_run_interactive_ssh")
203 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
204 +
205 + result = runner.invoke(app, ["ssh"])
206 + assert result.exit_code != 0
207 + interactive.assert_not_called()
208 +
209 +
210 +# --- E. --gpu + --tpu are both forwarded to `colab new` ----------------------
211 +
212 +
213 +def test_gpu_and_tpu_both_forwarded_to_new(mock_common_state, mocker):
214 + """`colab ssh --gpu T4 --tpu v5e1` forwards both to `colab new`, which
215 + resolves precedence -- SSH does not silently drop either."""
216 + sess = _make_session("auto")
217 + mock_common_state.store.list.return_value = {} # empty -> auto-create
218 + mock_common_state.store.get.return_value = sess
219 + new = mocker.patch("colab_cli.commands.session.new")
220 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
221 + mocker.patch.object(ssh_module, "_run_interactive_ssh", return_value=0)
222 +
223 + result = runner.invoke(app, ["ssh", "--gpu", "T4", "--tpu", "v5e1"])
224 + assert result.exit_code == 0
225 + new.assert_called_once()
226 + assert new.call_args.kwargs.get("gpu") == "T4"
227 + assert new.call_args.kwargs.get("tpu") == "v5e1"
228 +
229 +
230 +# --- F. --rm teardown is idempotent (signal path + finally path) -------------
231 +
232 +
233 +def test_proxy_mode_rm_teardown_idempotent(mock_common_state, mocker):
234 + """If a signal fires mid-bridge AND the finally clean-close path runs, the
235 + session is stopped exactly once (the `done` guard)."""
236 + import signal as _signal
237 +
238 + sess = _make_session("colab-ephem")
239 + mock_common_state.store.get.return_value = sess
240 + mock_common_state.resolve_session.return_value = "colab-ephem"
241 +
242 + handlers = {}
243 + mocker.patch(
244 + "signal.signal",
245 + side_effect=lambda sig, h: handlers.__setitem__(sig, h),
246 + )
247 + mocker.patch("os._exit") # keep the handler from killing the test process
248 + stop = mocker.patch("colab_cli.commands.session.stop")
249 + mocker.patch.object(ssh_module, "_resolve_pubkey", return_value="pk")
250 + mocker.patch.object(
251 + ssh_module, "_connect_websocket", return_value=MagicMock()
252 + )
253 +
254 + def bridge_then_hup(ws):
255 + handlers[_signal.SIGHUP](_signal.SIGHUP, None) # OpenSSH HUPs us
256 + return 0
257 +
258 + mocker.patch.object(
259 + ssh_module, "_bridge_proxy_mode", side_effect=bridge_then_hup
260 + )
261 +
262 + result = runner.invoke(
263 + app, ["ssh", "--proxy-mode", "-s", "colab-ephem", "--rm"]
264 + )
265 + assert result.exit_code == 0
266 + stop.assert_called_once_with(session="colab-ephem")
267 +
268 +
269 +# --- G. --proxy-mode --rm stops a *reused* session too -----------------------
270 +
271 +
272 +def test_proxy_mode_rm_stops_reused_session(mock_common_state, mocker):
273 + """proxy-mode --rm stops the bridged session on disconnect even when it
274 + already existed (ephemeral ~/.ssh/config host)."""
275 + sess = _make_session("colab")
276 + mock_common_state.store.get.return_value = sess # already exists
277 + mock_common_state.resolve_session.return_value = "colab"
278 + mocker.patch("signal.signal")
279 + stop = mocker.patch("colab_cli.commands.session.stop")
280 + _patch_proxy(mocker)
281 +
282 + result = runner.invoke(app, ["ssh", "--proxy-mode", "-s", "colab", "--rm"])
283 + assert result.exit_code == 0
284 + stop.assert_called_once_with(session="colab")
tests/test_ssh_wire_contract.py new
+391
@@ -0,0 +1,391 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Real-wire contract tests for `colab ssh`.
16 +
17 +Why this file exists: the mocked suite in tests/test_ssh.py mocks the websocket
18 +at the boundary (`_connect_websocket`, `websocket.WebSocket.connect`, or
19 +`_bridge_proxy_mode`), so the one line that actually puts bytes on the wire --
20 +
21 + ws.connect(url, header=[f"{_PUBKEY_HEADER}: {pubkey}"]) # ssh.py
22 +
23 +-- never runs under test. A wrong `_SSH_PATH` (e.g. the stale `/api/colab/ssh`)
24 +or a wrong `_PUBKEY_HEADER` would pass every mocked test while breaking every
25 +real connection. These tests close that gap: they stand up a real loopback
26 +WebSocket server, drive the client's real connect path (`_build_ws_url` +
27 +`_connect_websocket`, no mocks), and assert on the bytes the server actually
28 +received -- the request path, the runtime-proxy-token query param, and the
29 +pubkey header name+value. The server contract mirrors the google3 backend
30 +(third_party/colab/sources/{server.ts,websocket_to_ssh.ts}): route
31 +`/colab/ssh`, header `x-colab-ssh-pubkey`, 400 `unsupported key type`, 429
32 +already-active-session.
33 +
34 +Fully offline (~0.15s); allocates no Colab runtime.
35 +"""
36 +
37 +import base64
38 +import hashlib
39 +import socket
40 +import threading
41 +from dataclasses import dataclass, field
42 +from typing import Dict, List, Optional, Tuple
43 +
44 +from colab_cli.commands import ssh
45 +from colab_cli.state import SessionState
46 +import pytest
47 +import typer
48 +
49 +# A structurally-valid ed25519 public key. ed25519 is the only key type the
50 +# server accepts (RSA .pub tokens are `ssh-rsa`, rejected with 400 -- see
51 +# websocket_to_ssh.ts ALLOWED_KEY_TYPES). The distinctive marker lets us prove
52 +# the value crosses the wire verbatim.
53 +PUBKEY = (
54 + "ssh-ed25519 "
55 + "AAAAC3NzaC1lZDI1NTE5AAAAIWIRECONTRACTMARKER00000000000000000000 "
56 + "wire-contract@colab-cli-test"
57 +)
58 +TOKEN = "WIRE_CONTRACT_TOKEN_abc123"
59 +
60 +_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
61 +
62 +
63 +# --- loopback capture server -------------------------------------------------
64 +
65 +
66 +@dataclass
67 +class CapturedRequest:
68 + """The raw HTTP upgrade request the client sent, parsed."""
69 +
70 + raw: bytes
71 + method: str
72 + target: str # path + query, e.g. "/colab/ssh?colab-runtime-proxy-token=..."
73 + headers: Dict[str, str] = field(default_factory=dict)
74 + headers_lower: Dict[str, Tuple[str, str]] = field(default_factory=dict)
75 +
76 + @property
77 + def path(self) -> str:
78 + return self.target.split("?", 1)[0]
79 +
80 + @property
81 + def query(self) -> str:
82 + return self.target.split("?", 1)[1] if "?" in self.target else ""
83 +
84 +
85 +def _parse_request(raw: bytes) -> CapturedRequest:
86 + head = raw.split(b"\r\n\r\n", 1)[0].decode("latin-1")
87 + lines = head.split("\r\n")
88 + method, target, _proto = lines[0].split(" ", 2)
89 + req = CapturedRequest(raw=raw, method=method, target=target)
90 + for line in lines[1:]:
91 + if not line:
92 + continue
93 + name, _, value = line.partition(":")
94 + name = name.strip()
95 + value = value.strip()
96 + req.headers[name] = value
97 + req.headers_lower[name.lower()] = (name, value)
98 + return req
99 +
100 +
101 +class LoopbackWSServer:
102 + """A single-shot loopback server that captures the client's upgrade request.
103 +
104 + Two modes:
105 + * mode="handshake": complete a minimal RFC6455 101 handshake so the real
106 + `_connect_websocket` returns a live WebSocket (success path).
107 + * mode="status": return a controlled HTTP status + body (with
108 + Content-Length so websocket-client populates resp_body), exercising the
109 + real WebSocketBadStatusException error-mapping path.
110 + """
111 +
112 + def __init__(
113 + self,
114 + mode: str = "handshake",
115 + status: int = 400,
116 + reason: str = "Bad Request",
117 + body: bytes = b"",
118 + ):
119 + self.mode = mode
120 + self.status = status
121 + self.reason = reason
122 + self.body = body
123 + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
124 + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
125 + self._sock.bind(("127.0.0.1", 0))
126 + self._sock.listen(1)
127 + self.port = self._sock.getsockname()[1]
128 + self.captured: Optional[CapturedRequest] = None
129 + self._captured_evt = threading.Event()
130 + self._thread = threading.Thread(target=self._serve, daemon=True)
131 +
132 + def start(self) -> "LoopbackWSServer":
133 + self._thread.start()
134 + return self
135 +
136 + def _serve(self) -> None:
137 + self._sock.settimeout(10)
138 + try:
139 + conn, _ = self._sock.accept()
140 + except OSError:
141 + return
142 + with conn:
143 + conn.settimeout(10)
144 + data = b""
145 + try:
146 + while b"\r\n\r\n" not in data:
147 + chunk = conn.recv(4096)
148 + if not chunk:
149 + break
150 + data += chunk
151 + except OSError:
152 + pass
153 + if data:
154 + self.captured = _parse_request(data)
155 + self._captured_evt.set()
156 + try:
157 + conn.sendall(self._response(self.captured))
158 + except OSError:
159 + return
160 + if self.mode == "handshake":
161 + # Drain the client's close frame so its ws.close() returns
162 + # promptly, then let the socket close.
163 + try:
164 + conn.recv(4096)
165 + except OSError:
166 + pass
167 +
168 + def _response(self, req: Optional[CapturedRequest]) -> bytes:
169 + if self.mode == "handshake":
170 + key = (
171 + req.headers_lower.get("sec-websocket-key", ("", ""))[1]
172 + if req
173 + else ""
174 + )
175 + accept = base64.b64encode(
176 + hashlib.sha1((key + _WS_GUID).encode()).digest()
177 + ).decode()
178 + return (
179 + "HTTP/1.1 101 Switching Protocols\r\n"
180 + "Upgrade: websocket\r\n"
181 + "Connection: Upgrade\r\n"
182 + f"Sec-WebSocket-Accept: {accept}\r\n"
183 + "\r\n"
184 + ).encode()
185 + # status mode: MUST send Content-Length -- websocket-client only reads
186 + # the response body into WebSocketBadStatusException.resp_body when
187 + # Content-Length is present. Omitting it would silently degrade the
188 + # client to the generic 400 message and hide the `unsupported key type`
189 + # branch.
190 + headers = (
191 + f"HTTP/1.1 {self.status} {self.reason}\r\n"
192 + "Connection: close\r\n"
193 + f"Content-Length: {len(self.body)}\r\n"
194 + "\r\n"
195 + ).encode()
196 + return headers + self.body
197 +
198 + def wait_for_request(self, timeout: float = 10.0) -> CapturedRequest:
199 + if not self._captured_evt.wait(timeout):
200 + raise AssertionError("loopback server never captured a request")
201 + assert self.captured is not None
202 + return self.captured
203 +
204 + def close(self) -> None:
205 + try:
206 + self._sock.close()
207 + except OSError:
208 + pass
209 + self._thread.join(timeout=5)
210 +
211 +
212 +@pytest.fixture
213 +def ws_server_factory():
214 + servers: List[LoopbackWSServer] = []
215 +
216 + def _make(**kw) -> LoopbackWSServer:
217 + s = LoopbackWSServer(**kw).start()
218 + servers.append(s)
219 + return s
220 +
221 + yield _make
222 + for s in servers:
223 + s.close()
224 +
225 +
226 +def _session_for(port: int, token: str = TOKEN) -> SessionState:
227 + """A real SessionState (not a MagicMock) pointing at the loopback server."""
228 + return SessionState(
229 + name="wire-test",
230 + token=token,
231 + url=f"http://127.0.0.1:{port}",
232 + endpoint="wire-endpoint",
233 + )
234 +
235 +
236 +# --- the reusable contract assertion (this is what mutation must break) ------
237 +
238 +
239 +def _assert_wire_contract(
240 + req: CapturedRequest, token: str, pubkey: str
241 +) -> None:
242 + """Assert the captured upgrade request honors the SSH wire contract.
243 +
244 + A wrong `_SSH_PATH` breaks the path/query assertions; a wrong
245 + `_PUBKEY_HEADER` breaks the header-name assertion. The mocked suite can
246 + catch neither -- see test_mutation_* below, which run the SAME assertion
247 + against a mutated client and prove it raises.
248 + """
249 + assert req.method == "GET", f"expected GET, got {req.method!r}"
250 + assert req.path == "/colab/ssh", f"wrong route path: {req.path!r}"
251 + assert f"colab-runtime-proxy-token={token}" in req.query, (
252 + f"token missing/wrong in query: {req.query!r}"
253 + )
254 + assert "x-colab-ssh-pubkey" in req.headers_lower, (
255 + f"pubkey header absent; headers sent: {sorted(req.headers)!r}"
256 + )
257 + sent_name, sent_value = req.headers_lower["x-colab-ssh-pubkey"]
258 + assert sent_name == "X-Colab-Ssh-Pubkey", (
259 + f"header name on wire: {sent_name!r}"
260 + )
261 + assert sent_value == pubkey, f"pubkey not verbatim: {sent_value!r}"
262 +
263 +
264 +# --- the real-wire contract, success path ------------------------------------
265 +
266 +
267 +def test_wire_contract_path_token_and_pubkey_header(ws_server_factory):
268 + """The real connect path puts the contracted bytes on the wire.
269 +
270 + No mock of _connect_websocket / websocket.connect: the header-emitting line
271 + in ssh.py executes for real against a loopback server, and we assert on what
272 + the server received.
273 + """
274 + server = ws_server_factory(mode="handshake")
275 + session = _session_for(server.port)
276 +
277 + url = ssh._build_ws_url(session)
278 + ws = ssh._connect_websocket(url, PUBKEY) # real handshake, real header line
279 + try:
280 + ws.close()
281 + except Exception:
282 + pass
283 +
284 + req = server.wait_for_request()
285 + _assert_wire_contract(req, TOKEN, PUBKEY)
286 +
287 +
288 +def test_wire_contract_distinct_token_reaches_wire(ws_server_factory):
289 + """A per-session token is what actually appears in the query string."""
290 + server = ws_server_factory(mode="handshake")
291 + token = "DISTINCT_TOKEN_zzz999"
292 + session = _session_for(server.port, token=token)
293 +
294 + ws = ssh._connect_websocket(ssh._build_ws_url(session), PUBKEY)
295 + try:
296 + ws.close()
297 + except Exception:
298 + pass
299 +
300 + req = server.wait_for_request()
301 + assert f"colab-runtime-proxy-token={token}" in req.query
302 +
303 +
304 +# --- prove the contract catches the mutations the mocked suite misses ---------
305 +
306 +
307 +@pytest.mark.parametrize(
308 + ("attr", "value", "reached_wire"),
309 + [
310 + (
311 + "_SSH_PATH",
312 + "/api/colab/ssh",
313 + lambda req: req.path == "/api/colab/ssh",
314 + ),
315 + (
316 + "_PUBKEY_HEADER",
317 + "X-Wrong-Ssh-Pubkey",
318 + lambda req: (
319 + "x-wrong-ssh-pubkey" in req.headers_lower
320 + and "x-colab-ssh-pubkey" not in req.headers_lower
321 + ),
322 + ),
323 + ],
324 + ids=["wrong-ssh-path", "wrong-pubkey-header"],
325 +)
326 +def test_mutation_reaches_wire_and_breaks_contract(
327 + ws_server_factory, monkeypatch, attr, value, reached_wire
328 +):
329 + """Mutating a wire constant (path / header name) really changes the bytes
330 + on the wire and makes the contract assertion fail -- something the mocked
331 + suite cannot detect."""
332 + monkeypatch.setattr(ssh, attr, value)
333 + server = ws_server_factory(mode="handshake")
334 + session = _session_for(server.port)
335 +
336 + ws = ssh._connect_websocket(ssh._build_ws_url(session), PUBKEY)
337 + try:
338 + ws.close()
339 + except Exception:
340 + pass
341 +
342 + req = server.wait_for_request()
343 + assert reached_wire(req) # the mutation really reached the wire
344 + with pytest.raises(AssertionError):
345 + _assert_wire_contract(req, TOKEN, PUBKEY)
346 +
347 +
348 +# --- real HTTP status mapping (no mocked exception) --------------------------
349 +
350 +
351 +@pytest.mark.parametrize(
352 + ("status", "reason", "body", "must_contain"),
353 + [
354 + (400, "Bad Request", b"unsupported key type", "unsupported key type"),
355 + (
356 + 429,
357 + "Too Many Requests",
358 + b'{"error":"already-active-session"}',
359 + "Already-active SSH",
360 + ),
361 + ],
362 +)
363 +def test_real_status_mapping_via_loopback(
364 + ws_server_factory, capsys, status, reason, body, must_contain
365 +):
366 + """A real HTTP error from the server -> real WebSocketBadStatusException ->
367 + `_explain_handshake_failure` -> actionable stderr + exit code 1.
368 +
369 + Exercises the genuine error path (including websocket-client reading the
370 + Content-Length body into resp_body), which the mocked suite only reaches by
371 + hand-constructing the exception with a mocked resp_body.
372 + """
373 + server = ws_server_factory(
374 + mode="status", status=status, reason=reason, body=body
375 + )
376 + session = _session_for(server.port)
377 +
378 + with pytest.raises(typer.Exit) as exc_info:
379 + ssh._connect_websocket(ssh._build_ws_url(session), PUBKEY)
380 + assert exc_info.value.exit_code == 1
381 +
382 + err = capsys.readouterr().err
383 + assert must_contain in err
384 + if status == 400:
385 + # the remediation hint only fires when resp_body was really read
386 + assert "ssh-keygen -t ed25519" in err
387 +
388 + # the server must still have seen a well-formed contracted request even on
389 + # the rejection path.
390 + req = server.wait_for_request()
391 + _assert_wire_contract(req, TOKEN, PUBKEY)
tests/test_ssh_workdir.py new
+75
@@ -0,0 +1,75 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""`colab ssh` interactive shell: lands in /content and forwards --identity.
16 +
17 +An SSH login lands in root's home (/root); Colab users expect /content (where
18 +notebooks + uploads live). `_run_interactive_ssh` forces a PTY and runs a remote
19 +command that `cd`s to /content before exec'ing the login shell, and threads
20 +`--identity` through to both the outer `ssh -i` and the inner proxy command.
21 +"""
22 +
23 +from unittest.mock import MagicMock
24 +
25 +from colab_cli.commands import ssh as ssh_module
26 +import pytest
27 +
28 +
29 +def _make_session(
30 + name: str = "s1",
31 + url: str = "https://abc.colab.googleusercontent.com",
32 + token: str = "TOK",
33 + endpoint: str = "ep",
34 +):
35 + s = MagicMock()
36 + s.name = name
37 + s.url = url
38 + s.token = token
39 + s.endpoint = endpoint
40 + return s
41 +
42 +
43 +def test_default_remote_dir_is_content():
44 + assert ssh_module._DEFAULT_REMOTE_DIR == "/content"
45 +
46 +
47 +@pytest.mark.parametrize(
48 + "identity",
49 + [None, "/home/u/.ssh/id_ed25519"],
50 + ids=["no-identity", "with-identity"],
51 +)
52 +def test_interactive_ssh_builds_args(mocker, identity):
53 + call = mocker.patch("subprocess.call", return_value=0)
54 + rc = ssh_module._run_interactive_ssh(_make_session(), identity)
55 + assert rc == 0
56 +
57 + args = call.call_args.args[0]
58 + assert "-t" in args # PTY forced so the exec'd shell is interactive
59 + assert ssh_module._SSH_HOST in args
60 + # the host must precede the remote command (which is the final element)
61 + assert args.index(ssh_module._SSH_HOST) < len(args) - 1
62 +
63 + remote = args[-1]
64 + assert "cd /content" in remote
65 + assert "exec" in remote # execs a shell after the cd
66 + # a missing /content must not abort the shell (stderr suppressed).
67 + assert "2>/dev/null" in remote
68 +
69 + joined = " ".join(args)
70 + if identity:
71 + assert "-i" in args # outer ssh identity
72 + assert "--identity" in joined # inner proxy-mode identity
73 + else:
74 + assert "-i" not in args
75 + assert "--identity" not in joined