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)