Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ssh-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: SSH compatibility

on:
push:
paths:
- "src/colab_cli/**"
- "tests/test_ssh*.py"
- "tests/test_windows_ssh.py"
- "tests/conftest.py"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/ssh-compatibility.yml"
pull_request:
paths:
- "src/colab_cli/**"
- "tests/test_ssh*.py"
- "tests/test_windows_ssh.py"
- "tests/conftest.py"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/ssh-compatibility.yml"

permissions:
contents: read

jobs:
ssh:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
python-version: "3.12"
- run: uv sync --locked
- run: ssh -V
- run: uv run pytest tests/test_ssh.py tests/test_ssh_autocreate.py tests/test_ssh_lifecycle.py tests/test_ssh_wire_contract.py tests/test_ssh_workdir.py tests/test_windows_ssh.py
37 changes: 35 additions & 2 deletions docs/06_ssh_access.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
---
log:
2026-10-20: Passed the native Windows 11 / Python 3.12.14 live CPU SSH test: generated ProxyCommand, identity path with spaces, remote marker execution, and recorded-endpoint cleanup; the final sessions query reported no active assignments. SSH CI also passed on Windows, Linux, and macOS.
2026-10-20: Fixed Windows OpenSSH ProxyCommand argument quoting, local null-device selection, and cleanup signal registration. Added native Windows process/OpenSSH regression tests and a three-platform SSH CI job; live Colab verification remains required before claiming Windows support.
2026-09-25: Cleaned up keep-alive daemon references in session creation and teardown documentation.
2026-08-09: Added `--high-mem` passthrough when `colab ssh` auto-creates a runtime (forwards to `colab new --high-mem`).
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).
Expand Down Expand Up @@ -71,6 +73,9 @@ remote command, so to also land in `/content` add `RequestTTY yes` and
PTY (`-t`) and runs `cd /content 2>/dev/null; exec $SHELL -l` so you land in
`/content` (Colab's working dir) rather than root's home; a missing `/content`
falls back to the login home.
On native Windows, the ProxyCommand uses `subprocess.list2cmdline` for
CreateProcessW argument parsing; POSIX systems continue to use `shlex.join`.
The local `UserKnownHostsFile` uses `os.devnull` (`nul` on Windows).
4. **`--proxy-mode` bridge**: Bridges the WebSocket ↔ stdin/stdout for use as an
OpenSSH `ProxyCommand`. Honors every flag: `-s NAME` creates the session if
missing (creation output routed to stderr so stdout stays the clean ssh byte
Expand All @@ -80,9 +85,12 @@ remote command, so to also land in `/content` add `RequestTTY yes` and
**SIGHUP** (verified), not just stdin EOF, and Python's default SIGHUP action
would terminate the process before the teardown `finally` ran — leaking the
runtime. `--rm` therefore installs
SIGHUP/SIGTERM/SIGINT handlers that run the stop, idempotent with the
`finally`. `SIGKILL` cannot be intercepted, so a `kill -9`/hard crash can
handlers for the available SIGHUP/SIGTERM/SIGINT signals that run the stop,
idempotent with the `finally`. `SIGKILL` cannot be intercepted, so a `kill -9`/hard crash can
still leak; a normal disconnect is SIGHUP and is handled.
Windows has no SIGHUP, so registration skips it and retains SIGTERM/SIGINT.
Forced process termination on Windows can also bypass cleanup; this change
does not guarantee teardown after a killed process.
6. **Error handling**: The WebSocket upgrade maps each common HTTP status to an
actionable message:

Expand Down Expand Up @@ -135,3 +143,28 @@ command over the WebSocket bridge -- exercising the same connect ->
pubkey-header auth -> handshake -> bridge -> remote-exec path as the interactive
shell, minus the TTY -- asserts the RSA-key rejection, and verifies ``colab
stop`` leaves no orphan VM.

### Windows regression tests (`tests/test_windows_ssh.py`)
Real Windows child processes run the generated ProxyCommand using a Python
interpreter installed under a path with spaces. Assertions check exact argv
preservation for spaces, quotes, shell punctuation, backslashes, Unicode, and
identity paths. A separate test launches the real Windows OpenSSH client with
a local stub ProxyCommand and verifies its argv, without contacting Colab.
Portable tests also check registration without SIGHUP and the local null
device. Existing POSIX shell quoting and teardown tests remain in the suite.

The SSH CI workflow runs these and the existing SSH tests on Windows, Linux,
and macOS with Python 3.12. This covers SSH compatibility only. Interactive
console behavior, local file editing, self-update, and a live Colab session
require separate validation before the project's overall platform support
statement can change.

`uv run python integration/repro_windows_ssh/test.py` provides a native Windows
live test using cached OAuth2 credentials. It creates a CPU runtime, executes a
remote command through the generated ProxyCommand with a key path containing
spaces, exercises `--rm`, and cleans up the recorded endpoint on failure. Exit
77 is a prerequisite skip, not a successful live validation.

The native Windows live test passed on 2026-10-20 with Python 3.12.14 and the
Windows OpenSSH client. This validates remote command execution and cleanup
for this SSH path, not overall native Windows feature parity.
10 changes: 10 additions & 0 deletions integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ End-to-end tests that run against a **live Colab backend** (unlike the mocked un
| `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. |
| `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. |
| `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` |
| `repro_windows_ssh/` | Native Windows live CPU test: Windows OpenSSH launches the generated ProxyCommand, executes a remote marker command, and releases the recorded runtime. Requires cached OAuth2 credentials; exits 77 if prerequisites are missing. |


## Running
Expand All @@ -25,6 +26,15 @@ uv run bash integration/repro_run_command/test.sh
```
`uv run` ensures the local `colab` entry point is on `PATH`.

For the native Windows SSH check, run in PowerShell from the repository:

```powershell
uv run python integration/repro_windows_ssh/test.py
```

Authenticate first with `uv run colab --auth=oauth2 sessions`. The Windows test
uses no Bash or WSL, and preserves unrelated active sessions.

## Adding a scenario
1. Create `repro_<short_description>/`.
2. Add a script (`.sh` or `.py`) that demonstrates or verifies the issue.
Expand Down
120 changes: 120 additions & 0 deletions integration/repro_windows_ssh/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Native Windows live SSH check; allocates and releases one CPU runtime.

Run from the repository with `uv run python integration/repro_windows_ssh/test.py`.
Exit 77 means cached OAuth credentials are missing; no runtime is allocated.
"""

from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import uuid

from colab_cli.auth import TOKEN_CONFIG_PATH
from colab_cli.commands.ssh import _proxy_command, _ssh_base_args
from colab_cli.common import State


def colab(*args, check=True):
return subprocess.run(
[sys.executable, "-m", "colab_cli.cli", "--auth=oauth2", *args],
stdin=subprocess.DEVNULL,
check=check,
timeout=180,
)


def main():
if sys.platform != "win32":
print("SKIP: requires native Windows and Windows OpenSSH.")
return 77
if not Path(TOKEN_CONFIG_PATH).is_file():
print("SKIP: no cached OAuth2 credentials. First run:")
print(" uv run colab --auth=oauth2 sessions")
return 77
if not all(shutil.which(tool) for tool in ("ssh", "ssh-keygen")):
print("SKIP: install the Windows OpenSSH Client optional feature.")
return 77

# Check authentication before creating a billable resource.
colab("sessions")
name = "windows-ssh-" + uuid.uuid4().hex[:8]
state = State()
endpoint = None
try:
with tempfile.TemporaryDirectory(prefix="colab ssh ") as directory:
key = str(Path(directory) / "id_ed25519")
subprocess.run(
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", key],
check=True,
timeout=30,
)
colab("new", "-s", name)
session = state.store.get(name)
if session is None:
raise RuntimeError("Created session missing from local state")
endpoint = session.endpoint
print(f"Created endpoint: {endpoint}", flush=True)
proxy = _proxy_command(session, key) + " --rm"
args = _ssh_base_args(proxy, key)
args.extend(
[
"-F",
"NUL",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=60",
"root@colab-runtime",
"printf 'windows-ssh-ok\\n'",
]
)
result = subprocess.run(
args,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=120,
)
print(result.stdout)
print(result.stderr, file=sys.stderr)
result.check_returncode()
if "windows-ssh-ok" not in result.stdout.splitlines():
raise RuntimeError("Remote command marker missing")
finally:
# The bridge's --rm path should stop the runtime; also clean up on
# connection failure or timeout, using the recorded endpoint if needed.
saved = state.store.get(name)
endpoint = endpoint or (saved.endpoint if saved else None)
try:
colab("stop", "-s", name, check=False)
if endpoint:
active = {a.endpoint for a in state.client.list_assignments()}
if endpoint in active:
state.client.unassign(endpoint)
remaining = {a.endpoint for a in state.client.list_assignments()}
if endpoint in remaining:
raise RuntimeError(f"Runtime cleanup failed: {endpoint}")
finally:
colab("sessions")
print("PASS: native Windows SSH remote execution and runtime cleanup.")
return 0


if __name__ == "__main__":
raise SystemExit(main())
10 changes: 8 additions & 2 deletions src/colab_cli/commands/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,9 @@ def _proxy_command(session: SessionState, identity: Optional[str]) -> str:
]
if identity:
self_cmd.extend(["--identity", identity])
if sys.platform == "win32":
# Windows OpenSSH launches ProxyCommand via CreateProcessW.
return subprocess.list2cmdline(self_cmd)
return shlex.join(self_cmd)


Expand All @@ -417,7 +420,7 @@ def _ssh_base_args(proxy_command: str, identity: Optional[str]) -> list[str]:
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
f"UserKnownHostsFile={os.devnull}",
"-o",
"LogLevel=ERROR",
]
Expand Down Expand Up @@ -542,7 +545,10 @@ def _on_signal(signum, frame):
do_rm()
os._exit(0)

for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT):
for name in ("SIGHUP", "SIGTERM", "SIGINT"):
sig = getattr(signal, name, None)
if sig is None:
continue
try:
signal.signal(sig, _on_signal)
except (ValueError, OSError):
Expand Down
3 changes: 2 additions & 1 deletion tests/test_ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,14 @@ def recv_data(control_frame):
@pytest.mark.parametrize(
"identity", [None, "/k/id ed25519"], ids=["no-identity", "identity-space"]
)
def test_proxy_command_round_trips_through_the_shell(name, identity):
def test_proxy_command_round_trips_through_the_shell(name, identity, mocker):
"""The ProxyCommand string must re-parse into the exact argv.

`ssh` hands the ProxyCommand to /bin/sh, so every argument has to survive
word-splitting verbatim -- a hostile session name must arrive as one
literal argument, never as a new word or a substitution.
"""
mocker.patch.object(ssh_module.sys, "platform", "linux")
cmd = ssh_module._proxy_command(_make_session(name=name), identity)
argv = shlex.split(cmd)

Expand Down
15 changes: 12 additions & 3 deletions tests/test_ssh_autocreate.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from unittest.mock import MagicMock

from click import unstyle
from colab_cli.cli import app
from colab_cli.commands import ssh as ssh_module
import pytest
Expand Down Expand Up @@ -316,17 +317,25 @@ def test_proxy_mode_signal_handler_installation(
assert result.exit_code == 0
if expect_installed:
registered = {c.args[0] for c in sigmock.call_args_list}
assert {_signal.SIGHUP, _signal.SIGTERM, _signal.SIGINT} <= registered
expected = {
getattr(_signal, name)
for name in ("SIGHUP", "SIGTERM", "SIGINT")
if hasattr(_signal, name)
}
assert expected <= registered
else:
sigmock.assert_not_called()


# --- help --------------------------------------------------------------------


def test_ssh_help_advertises_autocreate_flags():
@pytest.mark.parametrize("force_color", [False, True])
def test_ssh_help_advertises_autocreate_flags(mocker, force_color):
"""`colab ssh --help` advertises --rm, --gpu, and --tpu."""
mocker.patch("typer.rich_utils.FORCE_TERMINAL", force_color)
mocker.patch("colab_cli.cli.auto_update.run_background_check")
result = runner.invoke(app, ["ssh", "--help"])
assert result.exit_code == 0
for flag in ("--rm", "--gpu", "--tpu"):
assert flag in result.output
assert flag in unstyle(result.output)
3 changes: 2 additions & 1 deletion tests/test_ssh_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ def test_proxy_mode_rm_teardown_idempotent(mock_common_state, mocker):
)

def bridge_then_hup(ws):
handlers[_signal.SIGHUP](_signal.SIGHUP, None) # OpenSSH HUPs us
sig = getattr(_signal, "SIGHUP", _signal.SIGTERM)
handlers[sig](sig, None)
return 0

mocker.patch.object(
Expand Down
Loading
Loading