google-colab-cli 0.7.4__tar.gz → 0.7.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/AGENTS.md +2 -2
  2. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/CHANGELOG.md +27 -0
  3. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/PKG-INFO +10 -3
  4. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/README.md +7 -1
  5. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/01_session_management.md +40 -2
  6. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/02_execution_and_interactive.md +3 -1
  7. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/03_file_management.md +2 -2
  8. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/04_automation_and_utility.md +26 -6
  9. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/05_run_command.md +2 -1
  10. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/06_ssh_access.md +87 -16
  11. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/integration/README.md +11 -0
  12. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/integration/repro_bundled_oauth/test.sh +8 -18
  13. google_colab_cli-0.7.6/integration/repro_legacy_token_migration/test.sh +136 -0
  14. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/integration/repro_piped_console/test.sh +5 -1
  15. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/integration/repro_run_command/test.sh +5 -1
  16. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/integration/repro_ssh/test.sh +19 -1
  17. google_colab_cli-0.7.6/integration/repro_windows_ssh/test.py +120 -0
  18. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/pyproject.toml +2 -1
  19. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/skills/colab-operator/SKILL.md +1 -1
  20. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/auth.py +150 -11
  21. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/cli.py +5 -1
  22. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/client.py +45 -17
  23. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/automation.py +28 -6
  24. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/execution.py +12 -3
  25. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/run.py +17 -4
  26. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/session.py +37 -11
  27. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/ssh.py +169 -44
  28. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/common.py +54 -4
  29. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/console.py +34 -9
  30. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/contents.py +3 -0
  31. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/history.py +5 -1
  32. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/runtime.py +114 -41
  33. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/state.py +19 -1
  34. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/utils.py +15 -0
  35. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/conftest.py +17 -0
  36. google_colab_cli-0.7.6/tests/mock_kernel.py +105 -0
  37. google_colab_cli-0.7.6/tests/test_auth.py +659 -0
  38. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_auth_adc.py +27 -0
  39. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_automation.py +25 -0
  40. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_cli.py +26 -0
  41. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_client.py +162 -5
  42. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_console.py +40 -1
  43. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_contents.py +37 -0
  44. google_colab_cli-0.7.6/tests/test_local_credentials.py +255 -0
  45. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_run.py +24 -0
  46. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_runtime.py +63 -35
  47. google_colab_cli-0.7.6/tests/test_runtime_options.py +79 -0
  48. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_ssh.py +237 -23
  49. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_ssh_autocreate.py +12 -3
  50. google_colab_cli-0.7.6/tests/test_ssh_cmd.py +203 -0
  51. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_ssh_lifecycle.py +2 -1
  52. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_ssh_wire_contract.py +147 -4
  53. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_ssh_workdir.py +19 -0
  54. google_colab_cli-0.7.6/tests/test_streaming.py +164 -0
  55. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_utils.py +23 -1
  56. google_colab_cli-0.7.6/tests/test_windows_ssh.py +165 -0
  57. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/uv.lock +153 -28
  58. google_colab_cli-0.7.4/tests/test_auth.py +0 -158
  59. google_colab_cli-0.7.4/tests/test_streaming.py +0 -98
  60. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/.githooks/pre-commit +0 -0
  61. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/.gitignore +0 -0
  62. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/.pre-commit-config.yaml +0 -0
  63. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/.python-version +0 -0
  64. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/CONTRIBUTING.md +0 -0
  65. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/LICENSE +0 -0
  66. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/cloudbuild.yaml +0 -0
  67. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/3042ab12-2026-05-07.png +0 -0
  68. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/demo.webm +0 -0
  69. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/docs/demos.md +0 -0
  70. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/examples/finetune_run.py +0 -0
  71. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/integration/repro_plot_redirection/test.sh +0 -0
  72. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/integration/repro_variable_persistence/test.sh +0 -0
  73. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/auto_update.py +0 -0
  74. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/__init__.py +0 -0
  75. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/files.py +0 -0
  76. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/usage.py +0 -0
  77. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/commands/utility.py +0 -0
  78. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/consumption.py +0 -0
  79. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/converter.py +0 -0
  80. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/oauth_config.json +0 -0
  81. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/src/colab_cli/repl.py +0 -0
  82. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_cli_log.py +0 -0
  83. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_common.py +0 -0
  84. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_consumption.py +0 -0
  85. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_consumption_client.py +0 -0
  86. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_exec.py +0 -0
  87. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_history.py +0 -0
  88. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_ipynb_exec.py +0 -0
  89. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_log_export.py +0 -0
  90. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_pay.py +0 -0
  91. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_readme.py +0 -0
  92. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_repl.py +0 -0
  93. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_resolution_logic.py +0 -0
  94. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_state.py +0 -0
  95. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_update.py +0 -0
  96. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_url.py +0 -0
  97. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_version.py +0 -0
  98. {google_colab_cli-0.7.4 → google_colab_cli-0.7.6}/tests/test_whoami.py +0 -0
@@ -5,7 +5,7 @@
5
5
  - **Common**: `common.py` centralizes shared `State` (lazy-loading) and session resolution.
6
6
  - **Client**: `ColabClient` handles API interactions (assignment, unassignment).
7
7
  - **Auth**: `auth.py` exposes a single `get_credentials(config_path, provider)` facade that dispatches on the `AuthProvider` enum. Two providers are supported, selected via the global `--auth=oauth2|adc` flag (default `oauth2`):
8
- - `oauth2`: public `google-auth-oauthlib` `InstalledAppFlow`, token cached at `~/.config/colab-cli/token.json`. Reads the client OAuth config from `-c/--client-oauth-config` (default `~/.colab-cli-oauth-config.json`), falling back to the **bundled** `src/colab_cli/oauth_config.json` resource (re-added in PR #41 / `9f44fe2`, 2026-05-29 — the earlier "removed in `20eb88e`" note was stale/incorrect; the file exists and `auth.py:_get_google_auth_credentials` loads it via `importlib.resources`). As of 2026-06-11 the flow is a **remote copy-paste flow**, not a localhost server: `_run_remote_flow` sets `redirect_uri=https://sdk.cloud.google.com/applicationdefaultauthcode.html` + `token_usage=remote`, prints the URL, and reads the pasted code via `input()`. NEVER revert to OOB (`urn:ietf:wg:oauth:2.0:oob`) — Google blocked it in 2022 ("OOB flow has been blocked"); the `sdk.cloud.google.com` redirect is registered only to the bundled cloud-SDK client (`764086051850-...`), so any other client id gets `redirect_uri_mismatch`. Server-side acceptance/rejection of these variants is verifiable GET-only by building the authorization URL and inspecting whether Google reaches sign-in vs. an OAuth error page (no resources allocated).
8
+ - `oauth2`: public `google-auth-oauthlib` `InstalledAppFlow`, token cached in the OS keyring via the Python `keyring` library (`service_name="google-colab-cli"`, `username="oauth_token"`), falling back to `_run_remote_flow` if the keyring is not accessible or locked (keyring failures are echoed to stderr; logs alone go to a file; on Linux, `_check_linux_keyring_locked` checks `secretstorage` beforehand and warns immediately when the `SecretService` collection is locked). A pre-keyring `~/.config/colab-cli/token.json` (`LEGACY_TOKEN_PATH`) is migrated into the keyring and deleted only after a successful keyring write, or immediately when the keyring already holds a token. Integration scripts assume no legacy file exists and detect OAuth2 availability via a keyring-only `has_oauth2_token` helper; the migration path is covered by `integration/repro_legacy_token_migration/test.sh` (isolated HOME + file-backed test keyring). Tests that must ignore the host keyring set `PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring`. Reads the client OAuth config from `-c/--client-oauth-config` (default `~/.colab-cli-oauth-config.json`), falling back to the **bundled** `src/colab_cli/oauth_config.json` resource (re-added in PR #41 / `9f44fe2`, 2026-05-29 — the earlier "removed in `20eb88e`" note was stale/incorrect; the file exists and `auth.py:_get_google_auth_credentials` loads it via `importlib.resources`). As of 2026-06-11 the flow is a **remote copy-paste flow**, not a localhost server: `_run_remote_flow` sets `redirect_uri=https://sdk.cloud.google.com/applicationdefaultauthcode.html` + `token_usage=remote`, prints the URL, and reads the pasted code via `input()`. NEVER revert to OOB (`urn:ietf:wg:oauth:2.0:oob`) — Google blocked it in 2022 ("OOB flow has been blocked"); the `sdk.cloud.google.com` redirect is registered only to the bundled cloud-SDK client (`764086051850-...`), so any other client id gets `redirect_uri_mismatch`. Server-side acceptance/rejection of these variants is verifiable GET-only by building the authorization URL and inspecting whether Google reaches sign-in vs. an OAuth error page (no resources allocated).
9
9
  - `adc`: Google Application Default Credentials via `google.auth.default()`. The CLI passes `scopes=PUBLIC_SCOPES` (which includes `colaboratory`) and re-applies via `creds.with_scopes()` for credential types that support it. **User credentials minted by `gcloud auth application-default login` ignore the `scopes=` kwarg AND raise `NotImplementedError` on `with_scopes`**: ADC users must explicitly re-authenticate with `gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/colaboratory`. `userinfo.email` is required by the session backend at `colab.research.google.com` (assign/unassign/sessions return 401 without it); `colaboratory` is retained for forward compatibility and other Colab features; `openid` and `cloud-platform` are mandated by `gcloud` itself, which rejects scope lists that omit `cloud-platform` with `Invalid value for [--scopes]`. Service-account / GCE / GKE / impersonated creds get the right scopes transparently via `with_scopes`.
10
10
  - **Backend Hosts**: Two distinct backends with different requirements:
11
11
  - `colab.research.google.com` (session backend / `tun/m/...`): accepts the `userinfo.email` scope. Handles assign, unassign, CCU info, and the contents API.
@@ -71,7 +71,7 @@ The package version is derived from the git tag via `hatch-vcs` (see `pyproject.
71
71
  12. **Amend Safety**: Before `git commit --amend`, explicitly verify all three preconditions: (a) the user requested amend OR a pre-commit hook auto-modified files for an otherwise-successful commit, (b) HEAD was created by you in this conversation (`git log -1 --format='%an %ae'`), (c) the commit has not been pushed to a remote. If any precondition fails, create a new commit instead. NEVER amend a failed/rejected commit — fix the issue and create a new one.
72
72
  13. **Run Integration Tests Yourself**: Re-read AGENTS.md "Agent Execution Limitations" before claiming you can't run a test. The CANNOT-run list is exclusively interactive commands (`colab auth`, `colab drivemount`, `colab repl`, `colab console`). Tests built on `colab new` / `colab stop` / `colab log` are non-interactive and you MUST run them yourself before declaring a fix complete. Encoded after running through a full implement-and-document cycle for a fix that the integration test would have falsified in 30 seconds — the cure was `uv run bash integration/repro_run_command/test.sh`.
73
73
  14. **Heed Research Caveats**: When a research subagent surfaces a caveat ("the proto allows it but the policy may reject"), treat it as a TODO to verify, not as a footnote. The validation pattern: shell out to the actual service with the proposed inputs and confirm the response matches expectations BEFORE writing the code that depends on it. For policy-gated paths, run a one-shot probe before committing to a design.
74
- 15. **Two distinct auths — never confuse them**: This codebase has two unrelated authentication concerns: (a) **CLI-to-Colab-control-plane**: how `Client` authenticates HTTP requests to `colab.research.google.com` and `colab.pa.googleapis.com`. Driven by the global `--auth={oauth2,adc}` flag and `auth.py:get_credentials`. The OAuth2 path is bootstrapped automatically by `google_auth_oauthlib`'s `InstalledAppFlow` on first invocation when `~/.config/colab-cli/token.json` doesn't exist; **no separate command is needed** — any `--auth=oauth2` invocation (e.g. `colab --auth=oauth2 sessions`) triggers the browser consent flow. (b) **VM-side credentials**: how the `colab auth` subcommand injects user GCP credentials *into* the running Colab kernel so notebook code (e.g. `gcloud`, BigQuery client) can make authenticated calls from inside the VM. This uses the `USE_AUTH_EPHEM='0'` gcloud-fallback path executed on the kernel via `ColabRuntime`. **NEVER tell a user to "run `colab auth`" as a prerequisite for fixing CLI-side auth issues** — they're orthogonal layers and the suggestion misleads.
74
+ 15. **Two distinct auths — never confuse them**: This codebase has two unrelated authentication concerns: (a) **CLI-to-Colab-control-plane**: how `Client` authenticates HTTP requests to `colab.research.google.com` and `colab.pa.googleapis.com`. Driven by the global `--auth={oauth2,adc}` flag and `auth.py:get_credentials`. The OAuth2 path is bootstrapped automatically by `google_auth_oauthlib`'s `InstalledAppFlow` on first invocation when no token is stored in `keyring` (or `keyring` is inaccessible); **no separate command is needed** — any `--auth=oauth2` invocation (e.g. `colab --auth=oauth2 sessions`) triggers the browser consent flow. (b) **VM-side credentials**: how the `colab auth` subcommand injects user GCP credentials *into* the running Colab kernel so notebook code (e.g. `gcloud`, BigQuery client) can make authenticated calls from inside the VM. This uses the `USE_AUTH_EPHEM='0'` gcloud-fallback path executed on the kernel via `ColabRuntime`. **NEVER tell a user to "run `colab auth`" as a prerequisite for fixing CLI-side auth issues** — they're orthogonal layers and the suggestion misleads.
75
75
  16. **Pydantic validation requires a schema**: `_issue_request` accepts an optional `schema=` and historically called `TypeAdapter(schema).validate_python(...)` unconditionally. When `schema=None` (a caller that doesn't care about the response body — e.g. unassign or fire-and-forget endpoints that return `[]`), this raises `pydantic.ValidationError: Input should be None`. Always guard with `if schema is None: return` after the empty-body short-circuit.
76
76
  17. **Suggest the branch-diff review command after committing**: The user reviews changes with `git diff main..<branch-name>` (full cumulative diff against `main`, not just the latest commit). After landing one or more commits on a feature branch, ALWAYS suggest the exact command — e.g. "Review with `git diff main..sort-help-commands`" — instead of `git show <sha>` (which only shows a single commit and misses context when a branch has multiple commits). Encoded 2026-05-05 after suggesting `git show 9d9c7da` for a branch the user wanted to review holistically.
77
77
  18. **Verify research-tool claims with primary sources**: Research tools and AI assistants can be confidently wrong, especially about edge cases or features outside their training corpus. When such a tool says "X is not used / not parsed / doesn't exist", treat it as a hypothesis to verify, not a fact. Always cross-check against the primary source (the actual code or config) — and when a tool names files, check whether the indirection chain it describes actually exists. The cost of believing the tool when it's wrong is shipping a non-functional feature; the cost of double-checking is small. Encoded 2026-05-05 after a `colab url` first-cut shipped the wrong URL format because of unverified output.
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  The package version is derived from the git tag via `hatch-vcs`; each release
9
9
  below corresponds to a tag of the same name.
10
10
 
11
+ ## [Unreleased]
12
+
13
+ ### Changed
14
+
15
+ - **ssh:** Every `colab ssh` WebSocket now sends `X-Colab-Client-Agent: colab-cli` and the query values `colab-client-agent=colab-cli` and `colab-client-agent=ssh-mode-<mode>`, where the mode is `interactive` for plain `colab ssh`, `cmd` for `colab ssh -- CMD`, and `proxy` otherwise.
16
+ - **ssh:** When the server rejects a connection with HTTP 429 and reports its
17
+ SSH session limit, `colab ssh` now says the limit is reached (N of N sessions
18
+ in use on the runtime) and that the limit depends on the Colab plan. A 429
19
+ without a usable limit keeps the previous message.
20
+
21
+ ### Added
22
+
23
+ - **ssh:** `colab ssh [-s NAME] -- CMD` runs one command on an existing runtime and exits with its status. Output is byte-exact when piped (a PTY only when stdin and stdout are terminals), and it never creates a runtime. Its handshake is tagged `ssh-mode-cmd`.
24
+
25
+ ### Fixed
26
+
27
+ - **ssh:** `colab --config FILE ssh` and `colab --auth adc ssh` now keep those options in the ProxyCommand that `colab ssh` starts, so it uses the same sessions file and auth provider instead of the defaults.
28
+ - **security:** Create the debug log (`~/.config/colab-cli/colab.log`),
29
+ `sessions.json`, `settings.json` and session history files readable only by
30
+ their owner, and tighten copies that older versions left readable by others
31
+ the next time the CLI uses them.
32
+ - **security:** Stop writing access tokens, runtime proxy tokens, OAuth codes,
33
+ client secrets, auth headers and cookies to the debug log. Logs written by
34
+ older versions may contain credentials and can be deleted; if other users
35
+ could read an old log, revoke the CLI's access in your Google Account and
36
+ sign in again.
37
+
11
38
  ## [0.7.1] - 2026-09-14
12
39
 
13
40
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: google-colab-cli
3
- Version: 0.7.4
3
+ Version: 0.7.6
4
4
  Summary: CLI for interacting with Colab.
5
5
  Project-URL: Homepage, https://github.com/googlecolab/google-colab-cli
6
6
  Project-URL: Repository, https://github.com/googlecolab/google-colab-cli
@@ -22,6 +22,7 @@ Requires-Dist: google-auth-oauthlib>=1.3.0
22
22
  Requires-Dist: google-auth>=2.49.1
23
23
  Requires-Dist: html2text>=2024.2.26
24
24
  Requires-Dist: jupyter-kernel-client==0.9.0
25
+ Requires-Dist: keyring>=25.7.0
25
26
  Requires-Dist: nbformat>=5.10.4
26
27
  Requires-Dist: packaging>=24.0
27
28
  Requires-Dist: prompt-toolkit>=3.0.52
@@ -31,7 +32,7 @@ Requires-Dist: requests>=2.32.5
31
32
  Requires-Dist: rich>=14.3.3
32
33
  Requires-Dist: typer>=0.24.1
33
34
  Requires-Dist: typing-extensions>=4.0
34
- Requires-Dist: websocket-client>=1.0
35
+ Requires-Dist: websocket-client>=1.6
35
36
  Description-Content-Type: text/markdown
36
37
 
37
38
  # Colab CLI
@@ -84,7 +85,7 @@ Run a CPU-based VM runtime, execute some code, and clean up:
84
85
  colab new
85
86
 
86
87
  # 2. Execute code from stdin
87
- echo "print('Hello from Google Colab!')" | colab exec
88
+ echo 'print("Hello from Google Colab!")' | colab exec
88
89
 
89
90
  # 3. Stop and release the VM resource
90
91
  colab stop
@@ -226,3 +227,9 @@ To view interactive walkthroughs of eleven real-world automated scenarios, check
226
227
  ## Contributing
227
228
 
228
229
  Feedback and contributions are welcome! Please read [`CONTRIBUTING.md`](./CONTRIBUTING.md) for details.
230
+
231
+ ---
232
+
233
+ ## Legal & Privacy
234
+
235
+ See Colab's [Terms of Service](https://research.google.com/colaboratory/tos_v6.html) and the [Google Privacy Policy](https://policies.google.com/privacy), which apply to usage of this CLI.
@@ -48,7 +48,7 @@ Run a CPU-based VM runtime, execute some code, and clean up:
48
48
  colab new
49
49
 
50
50
  # 2. Execute code from stdin
51
- echo "print('Hello from Google Colab!')" | colab exec
51
+ echo 'print("Hello from Google Colab!")' | colab exec
52
52
 
53
53
  # 3. Stop and release the VM resource
54
54
  colab stop
@@ -190,3 +190,9 @@ To view interactive walkthroughs of eleven real-world automated scenarios, check
190
190
  ## Contributing
191
191
 
192
192
  Feedback and contributions are welcome! Please read [`CONTRIBUTING.md`](./CONTRIBUTING.md) for details.
193
+
194
+ ---
195
+
196
+ ## Legal & Privacy
197
+
198
+ See Colab's [Terms of Service](https://research.google.com/colaboratory/tos_v6.html) and the [Google Privacy Policy](https://policies.google.com/privacy), which apply to usage of this CLI.
@@ -1,5 +1,11 @@
1
1
  ---
2
2
  log:
3
+ 2026-11-04: `--gpu` and `--tpu` now reject unknown values instead of silently selecting `A100` (GPU) or `v6e1` (TPU). `resolve_runtime_options()` uses the new `GPU_ACCELERATORS` / `TPU_ACCELERATORS` maps and raises a `typer.BadParameter` that names the flag and the supported list; `--help` is generated from the same maps so it cannot drift. Validation runs before any assignment request, in both `colab new` and `colab run`.
4
+ 2026-11-02: Added `_check_linux_keyring_locked` in `auth.py` to check if the active Linux `SecretService` keyring collection is locked via `secretstorage` before reading or writing OAuth tokens, logging and printing an immediate warning with the collection label, timeout, and `--auth=adc` hint while still attempting the keyring operation.
5
+ 2026-10-29: The debug log (`colab.log` and `--logtostderr`) masks access tokens, runtime proxy tokens, OAuth codes, client secrets, auth headers and cookies, including in percent-encoded URLs; `jupyter_kernel_client` logs at INFO so kernel stdin replies are not recorded.
6
+ 2026-10-29: `sessions.json`, `settings.json`, `colab.log` and session history files are created with mode 0600, and existing copies are tightened to 0600 the next time the CLI uses them (history files whenever the history logger starts).
7
+ 2026-10-21: Stored OAuth2 credentials in the OS native keyring via the `keyring` library instead of `~/.config/colab-cli/token.json`, falling back to `_run_remote_flow` if keyring is inaccessible. Keyring failures are reported on stderr, and an existing `token.json` is migrated into the keyring and then deleted (or deleted outright if the keyring already has a token).
8
+ 2026-10-14: Added configurable HTTP request timeouts and bounded allocation retries. Uncertain allocation POSTs are reconciled by GET using the same notebook hash before resubmission.
3
9
  2026-09-25: Runtime proxy tokens are now refreshed before expiry. `SessionState` persists `token_expires_at`, and `State.get_session` re-fetches the token via `GET /tun/m/assignments` when it's within 5 minutes of expiring.
4
10
  2026-09-25: Removed the keep-alive background daemon and related client/CLI ping functions. VM liveness is automatically maintained by the Colab backend based on kernel activity.
5
11
  2026-08-10: Added `colab usage` for account-level compute-unit rate/balance via `GET /tun/m/ccu-info` on the session backend (same bearer token as `colab new`).
@@ -29,6 +35,7 @@ Defines the specific hardware model.
29
35
  - **GPU Accelerators**:
30
36
  - `T4`: NVIDIA T4 (standard free-tier GPU).
31
37
  - `L4`: NVIDIA L4 (cost-effective modern GPU).
38
+ - `G4`: NVIDIA G4 (entry-level datacenter GPU).
32
39
  - `A100`: NVIDIA A100 (high-performance GPU).
33
40
  - `H100`: NVIDIA H100 (latest-gen performance GPU).
34
41
  - **TPU Accelerators**:
@@ -51,8 +58,39 @@ The CLI maps user flags to these backend parameters:
51
58
  - `colab new my-session --high-mem` -> adds `shape=hm` (when supported)
52
59
  - `colab new my-session --gpu A100 --high-mem` -> `variant=GPU`, `accelerator=A100`, `shape=hm`
53
60
 
61
+ `--gpu` accepts `T4`, `L4`, `G4`, `H100`, and `A100`; `--tpu` accepts `v5e1`
62
+ and `v6e1` (case-insensitive). Anything else is rejected with the supported
63
+ list before a VM is requested. Unknown values used to fall back silently to
64
+ `A100`/`v6e1`, which turned a typo in an automation script into a real,
65
+ billable allocation.
66
+
54
67
  ## Approach
55
68
 
69
+ ### HTTP Timeout and Allocation Recovery
70
+
71
+ `COLAB_CLI_REQUEST_TIMEOUT` sets the HTTP request timeout in seconds. If unset,
72
+ the session's existing default applies (AuthorizedSession defaults to 120 seconds).
73
+ An explicit `timeout=` passed by a caller takes precedence. This does not change
74
+ kernel execution timeouts, Colab runtime lifetime, or benchmark job timeouts.
75
+
76
+ Allocation POSTs retry `ReadTimeout` and HTTP 502/503/504 at most
77
+ `COLAB_CLI_ASSIGN_RETRIES` times (default 3; 0 disables retries). Between attempts,
78
+ the client waits `COLAB_CLI_RETRY_BACKOFF * 2**attempt` seconds (default 10, 20, 40).
79
+ It then queries the same notebook hash: an existing allocation is returned directly;
80
+ otherwise the refreshed XSRF token is used for the next POST. HTTP 412 still raises
81
+ `TooManyAssignmentsError`; other errors are not retried. Failed initial/recovery
82
+ GETs and exhausted POST retries propagate to the caller. There is no unlimited wait.
83
+
84
+ ```bash
85
+ COLAB_CLI_REQUEST_TIMEOUT=300 COLAB_CLI_ASSIGN_RETRIES=3 COLAB_CLI_RETRY_BACKOFF=10 \
86
+ colab new --session kernel-benchmark-v6e --tpu v6e1
87
+ ```
88
+
89
+ Regression tests cover timeout precedence, same-request recovery, refreshed
90
+ tokens, retry limits, exponential backoff and permanent-error handling. A live
91
+ allocation/recovery test is also required before declaring the behavior verified
92
+ end to end; mocked timeouts alone do not establish backend compatibility.
93
+
56
94
  ### 1. New Session (`colab new`)
57
95
  - **API**: `GET https://colab.sandbox.google.com/tun/m/assign` (based on HAR).
58
96
  - **Parameters**:
@@ -60,7 +98,7 @@ The CLI maps user flags to these backend parameters:
60
98
  - `nsa`: 1 (Standard flag observed in browser traces, typically for "next-gen session architecture").
61
99
  - `variant`: Selected from the list above.
62
100
  - `accelerator`: Selected from the list above.
63
- - **State Persistence**: The response contains a `token` and potentially a backend URL or identifier. We will store this in a local JSON file (default `~/.config/colab-cli/sessions.json`).
101
+ - **State Persistence**: The response contains a `token` and potentially a backend URL or identifier. We will store this in a local JSON file (default `~/.config/colab-cli/sessions.json`), readable only by its owner (mode 0600).
64
102
  - Format: `{ "session_name": { "token": "...", "backend_url": "...", "hardware": "..." } }`
65
103
 
66
104
  ### 2. Session Status (`colab status`)
@@ -96,7 +134,7 @@ The runtime proxy token (`runtimeProxyInfo.token`) authenticates every request t
96
134
  - **Global Flags**:
97
135
  - `-c`, `--client-oauth-config`: Path to the client secrets JSON file (default: `~/.colab-cli-oauth-config.json`).
98
136
  - `--config`: Path to the session state JSON file (default: `~/.config/colab-cli/sessions.json`).
99
- - **Token Storage**: Credentials are persisted to `~/.config/colab-cli/token.json` after the initial flow.
137
+ - **Token Storage**: Credentials are persisted to the OS native keyring via the `keyring` library (`service_name="google-colab-cli"`, `username="oauth_token"`) after the initial flow. If the keyring is inaccessible or locked, `_run_remote_flow` is invoked for the current run and a warning is printed to stderr. A legacy `~/.config/colab-cli/token.json` is migrated into the keyring on first use and deleted after a successful keyring write, or immediately if the keyring already holds a token.
100
138
  - Use `requests` for robust HTTP interactions and `pydantic` for schema validation.
101
139
  - Handle authentication headers (likely `Authorization: Bearer <token>` or cookies).
102
140
 
@@ -5,6 +5,8 @@ log:
5
5
  2026-05-07: Fixed `print_kitty` (used by `colab exec --output-image` and any image-producing exec) to no-op when `sys.stdout.isatty()` is false. The Kitty Graphics Protocol escape sequence is meaningless when stdout is a file or pipe and was visually corrupting captured output (a multi-KB base64 PNG blob would land in log files, grep targets, or showboat captures). Image bytes are still saved to disk via `handle_image`'s file-write path; only the inline-render attempt is suppressed.
6
6
 
7
7
  2026-06-04: Bumped the default `--timeout` for `colab exec` from 10s to 30s (and the matching `colab run` default) so brief silent tasks are less likely to hit a premature `TimeoutError`. Explicit `--timeout` overrides are unaffected.
8
+
9
+ 2026-10-24: `--timeout` for `colab exec` (and `colab run`) is now an idle limit: execution fails only after that many seconds without output, so code that keeps printing is never cut off, and `--timeout 0` means no limit. On 0.8.0/0.9.0 the old deadline was never enforced, so code that is silent for longer than `--timeout` now fails where it used to complete. `colab repl` and `colab install`, which pass no timeout, wait until execution finishes, or until the kernel dies or restarts. None of them busy-loop while waiting. `colab auth` and `colab drivemount` are unchanged. See #183.
8
10
  ---
9
11
 
10
12
  # Design: Execution and Interactive Interaction (`repl`, `exec`, `console`)
@@ -28,7 +30,7 @@ Execution involves sending Python code (or shell commands) to the Jupyter kernel
28
30
  - If file path is local: Read content, send as code.
29
31
  - If file path is remote: Execute `!python <path>`.
30
32
  - **Multi-Modal Output**: Handle `display_data` messages (e.g., `image/png`, `text/html`). For the CLI, we'll save images to temporary files and print their paths, or if the terminal supports it (e.g., iTerm2), inline them.
31
- - **Timeout Configuration**: Exposes a `--timeout` flag (default 30s) to allow long-running silent tasks (like model compilation or data downloading) to execute without being prematurely killed.
33
+ - **Timeout Configuration**: Exposes a `--timeout` flag (default 30s): the longest silence allowed before execution fails. Raise it so long-running silent tasks (like model compilation or data downloading) are not prematurely killed; `0` means no limit.
32
34
 
33
35
  ### 3. Console (`colab console`)
34
36
  - **Implementation**: Connects directly to the backend terminal endpoint (`/colab/tty`) via WebSockets using `websocket-client`.
@@ -31,7 +31,7 @@ File management on the Colab VM will be implemented using the Jupyter Contents A
31
31
  ### 3. Downloading Files (`colab download`)
32
32
  - **API**: `GET /api/contents/<remote_path>?content=1` (as seen in HAR).
33
33
  - **Response**: JSON with `content` field.
34
- - **Handling**: Decodes content based on `format` (text or base64) and saves it locally.
34
+ - **Handling**: Decodes content based on `format` (`base64`, `json`, or plain `text`) and saves it locally.
35
35
 
36
36
  ### 4. Deleting Files (`colab rm`)
37
37
  - **API**: `DELETE /api/contents/<remote_path>`.
@@ -59,4 +59,4 @@ TDD is mandatory for all file management features.
59
59
 
60
60
  ### 2. Error Cases
61
61
  - **Test Case**: Verify 404 responses are correctly caught and presented as a "File not found" error to the user.
62
- - **Test Case**: Verify correct handling of large file uploads exceeding API limits via kernel streaming.
62
+ - **Test Case**: Verify correct handling of large file uploads exceeding API limits via kernel streaming.
@@ -1,5 +1,10 @@
1
1
  ---
2
2
  log:
3
+ 2026-11-07: Optimized `_get_adc_credentials` in `auth.py` to stub `google.auth._cloud_sdk.get_project_id` around `google.auth.default()`. When ADC user credentials lack a `quota_project_id`, `google-auth` otherwise spawns `gcloud config get-value project` on every CLI invocation to resolve a `project_id` that `_get_adc_credentials` immediately discards (`_`).
4
+ 2026-11-04: `colab drivemount` now opens the Drive consent URL in the system browser (`utils.open_url_in_browser`, also used by `colab pay`/`colab url --open`) instead of only printing it. The URL is still printed first, and a missing browser opener (SSH, containers, CI) is treated as normal: the flow keeps waiting for the user exactly as before. The prompt moved into `commands/automation.py::show_drive_auth_url` so it can be unit-tested.
5
+ 2026-11-02: Added `_check_linux_keyring_locked` in `auth.py` to inspect the active Linux `SecretService` collection via `secretstorage` before `get_password`/`set_password`, warning immediately when the keyring is locked so users know why the CLI is waiting on an unlock prompt (and suggesting `--auth=adc`).
6
+ 2026-10-29: Session history files are created readable only by the owner (0600), and existing ones are tightened to 0600 whenever the history logger starts.
7
+ 2026-10-21: Migrated OAuth2 token persistence in `auth.py` from `~/.config/colab-cli/token.json` to the OS native password storage via the `keyring` library (`KEYRING_SERVICE_NAME = "google-colab-cli"`, `KEYRING_USERNAME = "oauth_token"`). When the keyring is empty, locked, or inaccessible, `_run_remote_flow` is invoked; keyring read/write failures print a warning to stderr without failing the command. An existing `~/.config/colab-cli/token.json` (`LEGACY_TOKEN_PATH`) is used when the keyring has no token, copied into the keyring, and deleted only after the keyring write succeeds; it is also deleted when the keyring already holds a token.
3
8
  2026-09-25: Cleaned up references to keep-alive background daemon and pre-flight pings. VM liveness is automatically maintained by the Colab backend based on active kernel activity.
4
9
  2026-08-10: Added `colab usage` for account-level compute-unit rate/balance via `GET /tun/m/ccu-info` on the session backend (same bearer token as `colab new`).
5
10
  2026-06-11: Replaced the `oauth2` provider's `run_local_server()` (localhost redirect) with a remote copy-paste flow (`_run_remote_flow` in `auth.py`). The CLI now prints an authorization URL built with `redirect_uri=https://sdk.cloud.google.com/applicationdefaultauthcode.html` and `token_usage=remote`, then reads the pasted authorization code via `input()` and exchanges it with `flow.fetch_token(code=...)`. This is the same flow `gcloud auth application-default login` uses and works identically in local and remote/headless/container environments, removing the heuristic of whether to auto-open a browser. Confirmed server-side acceptance with a live GET-only check against the bundled cloud-SDK client (`764086051850-...`); the OOB redirect and a non-bundled client id were both verified to be rejected (`OOB flow has been blocked` / `redirect_uri_mismatch`). Unit tests in `tests/test_auth.py` assert no localhost server is started, the redirect URI + `token_usage=remote` are set, and the pasted code is exchanged.
@@ -32,7 +37,18 @@ backend, selected via the global `--auth=<provider>` flag:
32
37
  `token_usage=remote`) using the registered HTTPS landing page
33
38
  `https://sdk.cloud.google.com/applicationdefaultauthcode.html`; the user
34
39
  signs in, copies the code Google displays, and pastes it back at the
35
- prompt. The refresh token is cached at `~/.config/colab-cli/token.json`.
40
+ prompt. The refresh token is cached in the OS native keyring via the
41
+ `keyring` library (`service_name="google-colab-cli"`, `username="oauth_token"`),
42
+ falling back to running `_run_remote_flow` if the keyring is not accessible
43
+ or locked. On Linux, `_check_linux_keyring_locked` inspects the active
44
+ `SecretService` collection via `secretstorage` before reading or writing
45
+ and prints an immediate warning if the collection is locked while waiting
46
+ on the unlock prompt. Keyring read/write failures are printed to stderr, since a
47
+ failed write means the user is prompted again on every command. A token
48
+ left at the pre-keyring path `~/.config/colab-cli/token.json` is migrated
49
+ into the keyring on first use and the file is deleted once the keyring
50
+ write succeeds (it is kept if the write fails). If the keyring already
51
+ holds a token, the legacy file is deleted.
36
52
  This is the same mechanism `gcloud auth application-default login` uses,
37
53
  and it behaves identically on local, remote, headless, and container
38
54
  hosts (no auto-opened browser, no bound port). We deliberately do **not**
@@ -65,9 +81,9 @@ sufficient for this host.
65
81
  How each provider supplies the scope:
66
82
 
67
83
  - **`oauth2`**: `PUBLIC_SCOPES` already includes `colaboratory`, so the
68
- InstalledAppFlow consent screen lists it. Existing cached tokens at
69
- `~/.config/colab-cli/token.json` that were minted before this change must
70
- be deleted to trigger a fresh consent flow.
84
+ InstalledAppFlow consent screen lists it. Existing cached tokens in the
85
+ keyring that were minted before a scope change must be cleared to trigger a
86
+ fresh consent flow.
71
87
  - **`adc`**: `google.auth.default(scopes=PUBLIC_SCOPES)` is called, and for
72
88
  credential subclasses that support `with_scopes` (service accounts,
73
89
  GCE/GKE metadata, impersonated) we re-apply via `creds.with_scopes(...)`.
@@ -123,7 +139,10 @@ How each provider supplies the scope:
123
139
  automatically interacts with the Colab backend
124
140
  (`/tun/m/credentials-propagation/`), prompts the user with the Google OAuth
125
141
  consent URL if needed, and dispatches the required `colab_reply` message to
126
- the `stdin` channel to unlock the kernel thread.
142
+ the `stdin` channel to unlock the kernel thread. The consent URL is also
143
+ passed to `webbrowser.open` (see `utils.open_url_in_browser`); when no
144
+ browser opener is available (SSH, containers, CI) the URL stays on screen
145
+ and the flow continues to wait for the user, unchanged.
127
146
  - **Timeout**: The kernel is silent (no iopub traffic) the entire time the
128
147
  user is OAuthing in their browser. To avoid the upstream 10s
129
148
  `jupyter_kernel_client` default raising `TimeoutError` mid-flow, this
@@ -135,7 +154,8 @@ How each provider supplies the scope:
135
154
  - **Action**: Capture the session's command history and outputs.
136
155
  - **Storage**: Maintain a local JSON-L file of all major operations,
137
156
  executions, and stdin interactions in
138
- `~/.config/colab-cli/history/<session_name>.jsonl`.
157
+ `~/.config/colab-cli/history/<session_name>.jsonl`, readable only by the
158
+ owner (mode 0600).
139
159
  - **Viewing**: `colab log list` and `colab log show <session>`.
140
160
  - **Conversion (Planned)**: Future expansion to convert history logs to
141
161
  `.ipynb` or `.html`.
@@ -5,6 +5,7 @@ log:
5
5
  2026-05-12: Initial design and implementation of `colab run <script.py> [args...]`. Combines `colab new` + `colab exec` + `colab stop` into a single fire-and-forget invocation so a Python file can use `#!/usr/bin/env -S colab run` as a shebang line and execute on a freshly-allocated Colab VM. Adds `--keep` (skip auto-stop), `--gpu` / `--tpu` (passthrough to session creation), `-s/--session` (name the ephemeral session), and propagates the script's exit status (non-zero on any uncaught exception in the kernel). The script's `sys.argv` is re-set inside the kernel to mirror native `python script.py arg1 arg2` semantics, and `__name__` is set to `"__main__"`.
6
6
  2026-05-12: Native CPython exit-code semantics for `sys.exit()` / `raise SystemExit(...)` from the script body. The Colab kernel reports a `SystemExit` as `output_type=='error'`, which under the previous logic would have (a) printed the IPython traceback (`An exception has occurred, use %tb...`) and (b) flagged the run as a failure regardless of the integer exit code. Now: `sys.exit()` / `sys.exit(0)` exit 0 silently; `sys.exit(N)` exits N; `sys.exit('msg')` exits 1 (matching CPython). The IPython "To exit: use 'exit', 'quit', or Ctrl-D." UserWarning is filtered via the prelude. Encoded after running `examples/gpu_hello.py` end-to-end and seeing the noisy `SystemExit: 0` traceback at the end of an otherwise-successful GPU run.
7
7
  2026-06-04: Bumped the default value of the `--timeout` flag from 10.0s to 30.0s so short-but-silent tasks aren't prematurely killed out of the box. Mirrors the same change for `colab exec`.
8
+ 2026-10-24: `--timeout` is now an idle limit: the run fails only after that many seconds without output, so a script that keeps printing can run as long as it needs, and `--timeout 0` means no limit. The CLI waits for kernel messages itself, in blocking slices of up to one second, instead of using `jupyter-kernel-client`'s `execute_interactive`. That deadline covered the whole run: 1.0.2 enforces it, failing runs after 30s even while they print, and 0.8.0/0.9.0 never enforce it, polling at ~100% CPU once it passes. As a result, on 0.8.0/0.9.0 a script that is silent for longer than `--timeout` now fails (and, without `--keep`, its session is stopped) where it used to complete. See #183.
8
9
  ---
9
10
 
10
11
  # Design: `colab run` — Shebang-Compatible One-Shot Execution
@@ -33,7 +34,7 @@ colab run [OPTIONS] SCRIPT [SCRIPT_ARGS]...
33
34
  | `--tpu` | str | None | Same set as `colab new --tpu` (v5e1, v6e1). |
34
35
  | `--high-mem` | bool | False | Same as `colab new --high-mem` — request high-RAM when supported. |
35
36
  | `--keep` | bool | False | Do **not** stop the session after the script finishes. |
36
- | `--timeout` | float | 30.0 | Timeout in seconds for code execution to prevent hanging on silent tasks. |
37
+ | `--timeout` | float | 30.0 | Seconds to wait without output before failing, so a hung or silent task doesn't block forever. Raise it for long silent tasks; `0` means no limit. |
37
38
 
38
39
  ### Shebang usage
39
40
  With `--keep` and `--gpu` baked into the shebang line, an entire one-file workload becomes:
@@ -1,5 +1,11 @@
1
1
  ---
2
2
  log:
3
+ 2026-11-09: `--identity/-i` now accepts either a `.pub` public key or a private key path: `_resolve_pubkey` reads the `.pub` file directly when present before falling back to `_pubkey_from_identity` (`ssh-keygen -y -f`), and `_ssh_base_args` strips `.pub` for `ssh -i` when the sibling private key exists on disk.
4
+ 2026-10-29: Added `colab ssh [-s NAME] -- CMD`, which runs one command on an existing session and exits with its status (a PTY only when stdin and stdout are terminals; neither it nor its ProxyCommand child creates a runtime). Its handshake carries `ssh-mode-cmd`, and the update banner is skipped for `ssh` when stdout is not a terminal. Added `tests/test_ssh_cmd.py`.
5
+ 2026-10-29: HTTP 429 now shows the server's SSH session limit ("N of N SSH sessions in use") when the body carries one.
6
+ 2026-10-29: The ProxyCommand that `colab ssh` starts now keeps a non-default global `--auth` and `--config` (as an absolute path), so `colab --config ALT ssh -s S` bridges S from ALT instead of the default sessions file.
7
+ 2026-10-29: Every `/colab/ssh` handshake now sends `X-Colab-Client-Agent: colab-cli` and the two `colab-client-agent` query values `colab-cli` and `ssh-mode-<mode>`. The mode is `interactive` for plain `colab ssh`, `cmd` for `colab ssh -- CMD` (both pass it to their ProxyCommand re-invocation through a hidden `--colab-internal-mode` option), and `proxy` otherwise.
8
+ 2026-10-20: Fixed Windows SSH ProxyCommand quoting, local null-device selection, and cleanup signal registration. Added native Windows regression and live integration tests; verified the SSH suite on Windows, Linux, and macOS, plus live Windows remote execution and runtime cleanup.
3
9
  2026-09-25: Cleaned up keep-alive daemon references in session creation and teardown documentation.
4
10
  2026-08-09: Added `--high-mem` passthrough when `colab ssh` auto-creates a runtime (forwards to `colab new --high-mem`).
5
11
  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).
@@ -22,18 +28,18 @@ runtime-proxy token so no separate credential handling is needed.
22
28
  ## User Surface
23
29
 
24
30
  ```
25
- colab ssh [OPTIONS]
31
+ colab ssh [OPTIONS] [-- CMD [ARGS]...]
26
32
  ```
27
33
 
28
34
  | Flag | Type | Default | Purpose |
29
35
  |---|---|---|---|
30
- | `-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. |
36
+ | `-s`, `--session` | str | auto | Session to connect to. If omitted, uses your only active session, auto-creates one when you have none (not with `-- CMD`), or errors when you have several. |
31
37
  | `--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. |
32
- | `-i`, `--identity` | str | auto | Private key for the public key sent to Colab. Default: first of `~/.ssh/id_ed25519`, `id_ecdsa`. |
38
+ | `-i`, `--identity` | str | auto | SSH key (`.pub` public key or private key path) for the public key sent to Colab. Default: first of `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub`. |
33
39
  | `--gpu` | str | None | GPU accelerator for a runtime this command creates (T4, L4, G4, H100, A100). |
34
40
  | `--tpu` | str | None | TPU accelerator for a runtime this command creates (v5e1, v6e1). |
35
41
  | `--high-mem` | bool | False | Request high-RAM when this command auto-creates a runtime (ignored when connecting to an existing session). |
36
- | `--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. |
42
+ | `--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. Ignored with `-- CMD`. |
37
43
 
38
44
  ### `~/.ssh/config` usage
39
45
  `--proxy-mode` turns `colab ssh` into a transport any SSH-based tool can drive:
@@ -53,24 +59,50 @@ where a bare `colab` may not be on `PATH`. External SSH tools run their own
53
59
  remote command, so to also land in `/content` add `RequestTTY yes` and
54
60
  `RemoteCommand cd /content 2>/dev/null; exec bash -l`.
55
61
 
62
+ ### Remote command
63
+ `colab ssh [-s NAME] -- CMD [ARGS]...` runs CMD in `/content` on an existing
64
+ session (never creating one); every word from CMD on goes to CMD, flags too.
65
+
66
+ - One argument is a shell command line (`-- 'ls / | wc -l'`); several are an
67
+ exact argv, so `*`, `~` and `$VAR` in separate arguments are not expanded.
68
+ Scripts can also pipe in: `echo cmd | colab ssh -s NAME -- bash`.
69
+ - `-t` only when stdin and stdout are terminals; otherwise no PTY, so piped
70
+ output is byte-exact. The exit status is CMD's; 255 means an ssh error or a
71
+ remote command killed by a signal (e.g. SIGPIPE from `| head`).
72
+ - A runtime accepts a limited number of concurrent SSH connections; past it
73
+ the server answers 429 and `colab ssh` prints its message.
74
+ - With a terminal on both ends, Ctrl-C reaches the remote command and ssh
75
+ exits 255. Piped, Ctrl-C stops only `colab ssh` (exit 130) and the remote
76
+ command may keep running: stop it with a pattern that cannot match its own
77
+ shell, e.g. `colab ssh -s NAME -- pkill -f '[s]leep 300'`, or `colab stop`.
78
+ - SSH sessions use a minimal environment: Python, pip and `nvidia-smi` work;
79
+ tools such as `gcloud` or `node` may need their directories added to `PATH`.
80
+
56
81
  ## Behavior
57
82
 
58
83
  1. **Session resolution / auto-create**: With `-s NAME`, resolves that session
59
84
  (via `state.resolve_session`, the same helper the other commands use). Bare
60
85
  `colab ssh` uses your only active session; with **no** session it auto-creates
61
- one (mirrors `colab new`: assign → persist `SessionState`); with **multiple**
86
+ one (mirrors `colab new`: assign → persist `SessionState`; never with
87
+ `-- CMD`); with **multiple**
62
88
  it errors and asks you to pick one with `-s`.
63
89
  2. **Connect**: Opens the WebSocket to `wss://<netloc>/colab/ssh?colab-runtime-proxy-token=<token>`
64
90
  and sends the resolved public key verbatim in the `X-Colab-Ssh-Pubkey` header
65
91
  (no transformation -- the bytes the user controls are exactly what the server
66
92
  receives). Only `ssh-ed25519` / `ecdsa-sha2-nistp{256,384,521}` keys are
67
- accepted.
93
+ accepted. It also sends `X-Colab-Client-Agent: colab-cli` and appends
94
+ `&colab-client-agent=colab-cli&colab-client-agent=ssh-mode-<mode>`: `interactive`
95
+ for plain `colab ssh`, `cmd` for `colab ssh -- CMD`, `proxy` for a
96
+ user-written `ProxyCommand` or an IDE.
68
97
  3. **Interactive shell**: Spawns the system `ssh` binary with the CLI re-invoked
69
- as its own `ProxyCommand` (`python -m colab_cli.cli ssh --proxy-mode`), so the
98
+ as its own `ProxyCommand` (`python -m colab_cli.cli ssh --proxy-mode --colab-internal-mode interactive`), so the
70
99
  WebSocket bridge and the interactive shell share one code path. It forces a
71
100
  PTY (`-t`) and runs `cd /content 2>/dev/null; exec $SHELL -l` so you land in
72
101
  `/content` (Colab's working dir) rather than root's home; a missing `/content`
73
102
  falls back to the login home.
103
+ On native Windows, the ProxyCommand uses `subprocess.list2cmdline` for
104
+ CreateProcessW argument parsing; POSIX systems continue to use `shlex.join`.
105
+ The local `UserKnownHostsFile` uses `os.devnull` (`nul` on Windows).
74
106
  4. **`--proxy-mode` bridge**: Bridges the WebSocket ↔ stdin/stdout for use as an
75
107
  OpenSSH `ProxyCommand`. Honors every flag: `-s NAME` creates the session if
76
108
  missing (creation output routed to stderr so stdout stays the clean ssh byte
@@ -80,9 +112,12 @@ remote command, so to also land in `/content` add `RequestTTY yes` and
80
112
  **SIGHUP** (verified), not just stdin EOF, and Python's default SIGHUP action
81
113
  would terminate the process before the teardown `finally` ran — leaking the
82
114
  runtime. `--rm` therefore installs
83
- SIGHUP/SIGTERM/SIGINT handlers that run the stop, idempotent with the
84
- `finally`. `SIGKILL` cannot be intercepted, so a `kill -9`/hard crash can
115
+ handlers for the available SIGHUP/SIGTERM/SIGINT signals that run the stop,
116
+ idempotent with the `finally`. `SIGKILL` cannot be intercepted, so a `kill -9`/hard crash can
85
117
  still leak; a normal disconnect is SIGHUP and is handled.
118
+ Windows has no SIGHUP, so registration skips it and retains SIGTERM/SIGINT.
119
+ Forced process termination on Windows can also bypass cleanup; this change
120
+ does not guarantee teardown after a killed process.
86
121
  6. **Error handling**: The WebSocket upgrade maps each common HTTP status to an
87
122
  actionable message:
88
123
 
@@ -92,17 +127,22 @@ remote command, so to also land in `/content` add `RequestTTY yes` and
92
127
  | 401 | Token invalid/expired — try `colab new` |
93
128
  | 403 | Forbidden — token lacks permission for this action (feature-off returns 404, not 403) |
94
129
  | 404 | SSH not exposed on this runtime — SSH is baked in at creation, so run `colab new` |
95
- | 429 | Another `colab ssh` is already connected — disconnect first |
130
+ | 429 | Session limit reached: "N of N SSH sessions in use", and the limit depends on the Colab plan; without a usable limit in the body, another `colab ssh` is connected: disconnect it first |
96
131
  | 502 | Runtime `sshd` unreachable — runtime may be unhealthy |
97
132
  | other / none | Raw status or a network-check hint |
98
133
 
134
+ 7. **Remote command**: `-- CMD` resolves an existing session and runs CMD as
135
+ described under Remote command. Its ProxyCommand child (mode `cmd`) only
136
+ resolves the session too, so neither process creates a runtime.
99
137
 
100
138
  ## Testing Strategy (TDD)
101
139
 
102
140
  ### Unit tests (`tests/test_ssh.py`)
103
141
  1. WebSocket URL construction (`wss` for https, `ws` for http; token query param).
104
- 2. Pubkey resolution — `--identity` (via `ssh-keygen -y -f`) and the `~/.ssh`
105
- default scan; missing-key and missing-identity exit paths.
142
+ 2. Pubkey resolution — `--identity` (reads the matching `.pub` file first,
143
+ whether passed with or without `.pub`, before falling back to `ssh-keygen -y
144
+ -f`) and the `~/.ssh` default scan; missing-key and missing-identity exit
145
+ paths.
106
146
  3. The full status→message map (400/401/403/404/429/502/other/none).
107
147
  4. Shell quoting for the `ProxyCommand` string.
108
148
  5. Session resolution (existing vs missing).
@@ -111,10 +151,13 @@ remote command, so to also land in `/content` add `RequestTTY yes` and
111
151
 
112
152
  ### Wire-contract tests (`tests/test_ssh_wire_contract.py`)
113
153
  Stands up a loopback WebSocket server and drives the real connect path (no mock)
114
- to assert the request path, the `colab-runtime-proxy-token` query param, and the
115
- `X-Colab-Ssh-Pubkey` header reach the wire verbatim. Includes mutation tests that
116
- fail if `_SSH_PATH`/`_PUBKEY_HEADER` drift, plus real HTTP 400/429 mapping via a
117
- genuine `WebSocketBadStatusException`.
154
+ to assert the request path, the `colab-runtime-proxy-token` query param, the
155
+ `X-Colab-Ssh-Pubkey` header, the `X-Colab-Client-Agent` header and the two
156
+ `colab-client-agent` query values reach the wire verbatim. Includes mutation tests
157
+ that fail if `_SSH_PATH`/`_PUBKEY_HEADER`, the client-agent header or a mode value
158
+ drift, real HTTP 400/429 mapping (including a 429 body with a limit) via a genuine
159
+ `WebSocketBadStatusException`, and real CLI child processes for plain `colab ssh`
160
+ (`interactive`) and a user `ProxyCommand` (`proxy`).
118
161
 
119
162
  ### Auto-create & proxy-mode tests (`tests/test_ssh_autocreate.py`)
120
163
  Bare `colab ssh` create vs reuse vs ambiguous; `--gpu/--tpu` passthrough; `--rm`
@@ -126,6 +169,11 @@ SIGHUP cleanup handler being installed only under `--rm`.
126
169
  Interactive `ssh` forces a PTY (`-t`) and runs a `cd /content` remote command
127
170
  (host before the command, `2>/dev/null` tolerance for a missing directory).
128
171
 
172
+ ### Remote-command tests (`tests/test_ssh_cmd.py`)
173
+ `-- CMD` forwarding and quoting, the `-t`/`-T` rule, exit status, no
174
+ auto-create in the parent or its ProxyCommand child, `ssh-mode-cmd` at
175
+ `websocket.WebSocket.connect`, closed stdio, and the update-banner gate.
176
+
129
177
  ### Integration test (`integration/repro_ssh/`)
130
178
  Two parts. An offline smoke that always runs (no VM): ``--help`` advertises the
131
179
  documented flags, and an unknown session exits 2 with an actionable message. A
@@ -135,3 +183,26 @@ command over the WebSocket bridge -- exercising the same connect ->
135
183
  pubkey-header auth -> handshake -> bridge -> remote-exec path as the interactive
136
184
  shell, minus the TTY -- asserts the RSA-key rejection, and verifies ``colab
137
185
  stop`` leaves no orphan VM.
186
+
187
+ ### Windows regression tests (`tests/test_windows_ssh.py`)
188
+ Real Windows child processes run the generated ProxyCommand using a Python
189
+ interpreter installed under a path with spaces. Assertions check exact argv
190
+ preservation for spaces, quotes, shell punctuation, backslashes, Unicode, and
191
+ identity paths. A separate test launches the real Windows OpenSSH client with
192
+ a local stub ProxyCommand and verifies its argv, without contacting Colab.
193
+ Portable tests also check registration without SIGHUP and the local null
194
+ device. Existing POSIX shell quoting and teardown tests remain in the suite.
195
+
196
+ These and the existing SSH tests passed on Windows, Linux, and macOS with
197
+ Python 3.12. This covers SSH compatibility only; interactive console behavior,
198
+ local file editing, and self-update require separate platform validation.
199
+
200
+ `uv run python integration/repro_windows_ssh/test.py` provides a native Windows
201
+ live test using cached OAuth2 credentials. It creates a CPU runtime, executes a
202
+ remote command through the generated ProxyCommand with a key path containing
203
+ spaces, exercises `--rm`, and cleans up the recorded endpoint on failure. Exit
204
+ 77 is a prerequisite skip, not a successful live validation.
205
+
206
+ The native Windows live test passed on 2026-10-20 with Python 3.12.14 and the
207
+ Windows OpenSSH client. This validates remote command execution and cleanup
208
+ for this SSH path, not overall native Windows feature parity.
@@ -16,7 +16,9 @@ End-to-end tests that run against a **live Colab backend** (unlike the mocked un
16
16
  | `repro_variable_persistence/` | Variables persist across `colab exec` calls in the same session. |
17
17
  | `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. |
18
18
  | `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. |
19
+ | `repro_legacy_token_migration/` | Fast smoke test (~5s, no VM): a pre-keyring `~/.config/colab-cli/token.json` is migrated into the keyring and deleted, deleted when the keyring already has a token, and kept (with a stderr warning) when the keyring is inaccessible. Uses a throwaway HOME and file-backed test keyring. |
19
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
+ | `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. |
20
22
 
21
23
 
22
24
  ## Running
@@ -25,6 +27,15 @@ uv run bash integration/repro_run_command/test.sh
25
27
  ```
26
28
  `uv run` ensures the local `colab` entry point is on `PATH`.
27
29
 
30
+ For the native Windows SSH check, run in PowerShell from the repository:
31
+
32
+ ```powershell
33
+ uv run python integration/repro_windows_ssh/test.py
34
+ ```
35
+
36
+ Authenticate first with `uv run colab --auth=oauth2 sessions`. The Windows test
37
+ uses no Bash or WSL, and preserves unrelated active sessions.
38
+
28
39
  ## Adding a scenario
29
40
  1. Create `repro_<short_description>/`.
30
41
  2. Add a script (`.sh` or `.py`) that demonstrates or verifies the issue.
@@ -17,26 +17,20 @@
17
17
  #
18
18
  # This test verifies that the CLI correctly falls back to using the bundled
19
19
  # oauth_config.json containing the default client ID when no local config is present.
20
- # It simulates a fresh user flow by temporarily hiding existing tokens/configs,
21
- # starting the OAuth flow, and asserting that the printed authorization URL
22
- # contains the correct Client ID.
20
+ # It simulates a fresh user flow by temporarily hiding an existing config and
21
+ # the keyring token, starting the OAuth flow, and asserting that the printed
22
+ # authorization URL contains the correct Client ID.
23
23
  #
24
24
 
25
25
  set -e
26
26
 
27
- TOKEN_PATH="$HOME/.config/colab-cli/token.json"
28
27
  CONFIG_PATH="$HOME/.colab-cli-oauth-config.json"
29
28
  BACKUP_SUFFIX=".backup.$(date +%s)"
30
29
 
31
- TOKEN_BACKUP=""
32
30
  CONFIG_BACKUP=""
33
31
 
34
32
  cleanup() {
35
33
  echo "[*] Cleaning up backups..."
36
- if [ -n "$TOKEN_BACKUP" ] && [ -f "$TOKEN_BACKUP" ]; then
37
- mv "$TOKEN_BACKUP" "$TOKEN_PATH"
38
- echo "[*] Restored token.json"
39
- fi
40
34
  if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then
41
35
  mv "$CONFIG_BACKUP" "$CONFIG_PATH"
42
36
  echo "[*] Restored .colab-cli-oauth-config.json"
@@ -44,13 +38,7 @@ cleanup() {
44
38
  }
45
39
  trap cleanup EXIT
46
40
 
47
- # 1. Back up existing configuration files if they exist
48
- if [ -f "$TOKEN_PATH" ]; then
49
- TOKEN_BACKUP="${TOKEN_PATH}${BACKUP_SUFFIX}"
50
- mv "$TOKEN_PATH" "$TOKEN_BACKUP"
51
- echo "[*] Temporarily backed up token.json to $TOKEN_BACKUP"
52
- fi
53
-
41
+ # 1. Back up the existing client config if it exists
54
42
  if [ -f "$CONFIG_PATH" ]; then
55
43
  CONFIG_BACKUP="${CONFIG_PATH}${BACKUP_SUFFIX}"
56
44
  mv "$CONFIG_PATH" "$CONFIG_BACKUP"
@@ -63,7 +51,9 @@ echo "[*] Running 'colab --auth=oauth2 sessions' (expecting to trigger browser f
63
51
  # We redirect output to a file so we can inspect it.
64
52
  OUTPUT_LOG=$(mktemp)
65
53
  set +e
66
- PYTHONUNBUFFERED=1 timeout 5 uv run colab --auth=oauth2 sessions > "$OUTPUT_LOG" 2>&1
54
+ # The null keyring backend hides any token cached in the host's OS keyring.
55
+ PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \
56
+ PYTHONUNBUFFERED=1 timeout 5 uv run colab --auth=oauth2 sessions > "$OUTPUT_LOG" 2>&1
67
57
  EXIT_CODE=$?
68
58
  set -e
69
59
 
@@ -76,7 +66,7 @@ echo "----------------------------------------------"
76
66
  EXPECTED_CLIENT_ID="764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com"
77
67
 
78
68
  # The command should have printed the authorization URL
79
- if ! grep -q "Please visit this URL to authorize this application" "$OUTPUT_LOG"; then
69
+ if ! grep -q "To authorize colab-cli, visit this URL" "$OUTPUT_LOG"; then
80
70
  echo "[FAILURE] OAuth prompt message not found in output."
81
71
  rm -f "$OUTPUT_LOG"
82
72
  exit 1