Skip to content
Open
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- `colab.research.google.com` (session backend / `tun/m/...`): accepts the `userinfo.email` scope. Handles assign, unassign, CCU info, and the contents API.
- **Runtime**: `ColabRuntime` wraps `jupyter-kernel-client` for execution.
- **State**:
- `StateStore` persists session metadata in `~/.config/colab-cli/sessions.json`.
- `StateStore` persists session metadata in `~/.config/colab-cli/sessions.json`; attached sessions (a runtime the CLI did not create) and their tokens go to the derived `<stem>.attached.json` beside it, and the file a record is read from decides its `attached` flag.
- Persistent settings are in `~/.config/colab-cli/settings.json`.
- **History**: `HistoryLogger` records structured events in `~/.config/colab-cli/history/*.jsonl`.

Expand Down Expand Up @@ -47,7 +47,7 @@ The package version is derived from the git tag via `hatch-vcs` (see `pyproject.
1. **Verify a clean, current `main`**: `git fetch origin && git checkout main && git pull --ff-only origin main`. Confirm `git status` is clean. If `main` has diverged or there are unmerged feature branches the user expects in the release, stop and ask.
2. **Identify unreleased commits**: Find the last release tag with `git describe --tags --abbrev=0`, then list commits since then with `git log <last-tag>..HEAD --oneline`. These are what the new changelog section must cover. Cross-reference each commit's PR number (the `(#NN)` suffix in the merge commit subject) — every entry in the changelog should cite at least one PR.
3. **Infer the next version (semver)**: Categorize each commit using the Keep-a-Changelog buckets already present in `CHANGELOG.md` (`Added`, `Changed`, `Fixed`, `Removed`, `Deprecated`, `Security`). Pick the bump:
- **Major** (`X.0.0`): any breaking change to the CLI surface, flag semantics, on-disk state schema (`sessions.json` / `settings.json`), or persisted token format.
- **Major** (`X.0.0`): any breaking change to the CLI surface, flag semantics, on-disk state schema (`sessions.json` / `<stem>.attached.json` / `settings.json`), or persisted token format.
- **Minor** (`0.X.0`): at least one `Added` entry (new subcommand, new flag, new capability) with no breaking changes.
- **Patch** (`0.0.X`): only `Fixed`, `Changed`, `Removed` (non-breaking cleanup), `Deprecated`, or `Security` entries.
Confirm the inferred version with the user before proceeding — never tag without that confirmation, even when the bump seems obvious.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ below corresponds to a tag of the same name.
### Added

- **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`.
- **sessions:** Sessions attached to a runtime the CLI did not create are kept in their own owner-only file beside the sessions file (`sessions.attached.json`, or `<stem>.attached.json` with `--config <stem>.json`), so older versions never see them; `--config` refuses a path ending in `.attached.json`. If one name is in both files, commands that read the store stop with an error before any Colab request, and `colab stop -s NAME` forgets the attached record without stopping its runtime.

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Run `colab <command> --help` to view specific options, defaults, and detailed he
### Global Options
* `--auth {oauth2,adc}` — Authentication strategy for the Colab API (default: `adc`).
* `-c, --client-oauth-config PATH` — Path to public OAuth client credentials configuration (default: `~/.colab-cli-oauth-config.json`).
* `--config PATH` — Path to local session metadata storage (default: `~/.config/colab-cli/sessions.json`).
* `--config PATH` — Path to local session metadata storage (default: `~/.config/colab-cli/sessions.json`). Sessions attached to a runtime the CLI did not create, and their tokens, are kept beside it in `<stem>.attached.json`, where `<stem>` is the path without its extension (`sessions.attached.json` by default); a path ending in `.attached.json` is refused.
* `--logtostderr` — Direct debug logging output to stderr.

---
Expand Down Expand Up @@ -146,7 +146,7 @@ colab stop -s analysis
* **Machine shape:** Use `--high-mem` with `colab new`, `colab run`, or `colab ssh` (when auto-creating a runtime) to request a high-RAM machine shape. Requires Colab Pro or Pro+ entitlement for supported accelerators (CPU, T4, A100, etc.). L4 and TPU runtimes ignore this flag because they only offer one shape. Machine shape is shown in `colab sessions` and `colab status`.
* **TTY Requirements:** The interactive commands `repl` and `console` require a local TTY. When running inside automated scripts or pipelines, make sure to pipe stdin (e.g., `echo "print(1)" | colab repl`) to trigger non-interactive execution modes.
* **Transparent Code Execution:** When calling `colab exec -f file.py`, the CLI reads the file locally and transmits its content to the remote kernel. You do not need to manually upload files before execution.
* **Storage & State Paths:** Session tokens and metadata are stored at `~/.config/colab-cli/sessions.json`. Global CLI settings are located at `~/.config/colab-cli/settings.json`. These can be customized or isolated via the global `--config` flag.
* **Storage & State Paths:** Session tokens and metadata are stored at `~/.config/colab-cli/sessions.json`; sessions attached to a runtime the CLI did not create, and their tokens, are kept beside it in `sessions.attached.json` (`<stem>.attached.json` for `--config <stem>.json`). Global CLI settings are located at `~/.config/colab-cli/settings.json`. These can be customized or isolated via the global `--config` flag.

### Ephemeral Accelerator Jobs

Expand Down
6 changes: 4 additions & 2 deletions docs/01_session_management.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
log:
2026-11-04: `StateStore` keeps attached sessions in their own owner-only file beside the sessions file; a name in both files fails with an error that names `colab stop -s NAME`, which forgets the attached record without unassigning. A sessions path that names an attached file, or whose attached path links to another kind of file, is refused (hard links and copies are not detected).
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`.
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.
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.
Expand Down Expand Up @@ -133,13 +134,14 @@ The runtime proxy token (`runtimeProxyInfo.token`) authenticates every request t
- **Authentication**: Uses `google-auth-oauthlib` to perform a local server OAuth flow.
- **Global Flags**:
- `-c`, `--client-oauth-config`: Path to the client secrets JSON file (default: `~/.colab-cli-oauth-config.json`).
- `--config`: Path to the session state JSON file (default: `~/.config/colab-cli/sessions.json`).
- `--config`: Path to the session state JSON file (default: `~/.config/colab-cli/sessions.json`). Attached sessions and their tokens are kept beside it in `<stem>.attached.json`, where `<stem>` is the path without its extension; a path ending in `.attached.json` is refused.
- **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.
- Use `requests` for robust HTTP interactions and `pydantic` for schema validation.
- Handle authentication headers (likely `Authorization: Bearer <token>` or cookies).

### State Persistence & File Locking
- **Stores**: `StateStore` (`sessions.json`) and `SettingsStore` (`settings.json`) both derive from `_LockedFileStore`, which guards concurrent access across independent `colab` invocations.
- **Attached sessions**: `StateStore` keeps records with `attached=True` (a runtime the CLI did not create) in a second locked, owner-only file derived from the sessions path (`sessions.attached.json`, or `<stem>.attached.json` for `--config <stem>.json`), so versions that read only `sessions.json` never see them. `add` routes on `attached`, `get` and `list` read both files, and `remove` removes from both. The file a record is read from decides its `attached` flag, so a refresh writes each record back to its own file. A name present in both raises `DuplicateSessionError` from `get` and `list`, so commands that read the store stop before any Colab request (commands that assign first, such as `colab new -s`, are not covered yet); `colab stop -s NAME` recovers by forgetting the attached record without unassigning. A sessions path ending in `.attached.json` (in any case, or a symlink to one), or one whose derived attached path is a symlink to a file not named `*.attached.json`, raises `AttachedStorePathError`. The CLI applies this check to `--config` at startup, before any command runs. Hard links and renamed copies are not detected in either direction: do not hard-link or copy an attached file or a sessions file under another store's name.
- **Cross-platform locking**: Locking uses the [`filelock`](https://pypi.org/project/filelock/) library rather than `fcntl.flock`. `fcntl` is POSIX-only and is unavailable on Windows, so the original implementation crashed on import there. `filelock` provides the same advisory cross-process locking on Linux, macOS, and Windows.
- **Shared vs. exclusive**: Each store owns a `filelock.ReadWriteLock` bound to a sidecar file (`<path>.lock`). Reads acquire `read_lock()` (shared — multiple concurrent readers allowed) and writes acquire `write_lock()` (exclusive). This preserves the `LOCK_SH`/`LOCK_EX` distinction of the previous `fcntl` implementation.
- **`is_singleton=False`**: The `ReadWriteLock` is created with `is_singleton=False`. With `filelock`'s default (`True`), two `ReadWriteLock` objects for the same path *within a single process* are deduplicated into one reentrant lock; its reentrancy guard then raises `RuntimeError` when two threads each construct their own `StateStore` and contend for the write lock. Disabling the singleton registry makes each store's lock independent so they serialize via the underlying file lock instead.
Expand All @@ -155,7 +157,7 @@ TDD is mandatory for all session management features.

### 2. State Store Validation
- **Test Case**: Verify `StateStore` correctly handles file locking and multiple concurrent reads/writes.
- **Test Case**: Verify `--config` override correctly directs all operations to the specified file path.
- **Test Case**: Verify `--config` override correctly directs all operations to the specified file path and its `<stem>.attached.json`, and refuses a path ending in `.attached.json`.
- **Test Case (cross-platform locking)**: Verify the store locks via `filelock.ReadWriteLock` on the `<path>.lock` sidecar and does not import the POSIX-only `fcntl`.
- **Test Case (shared/exclusive semantics)**: Verify reads go through `read_lock()` and writes through `write_lock()`.
- **Test Case (cross-process exclusion)**: Hold the write lock from a separate process and confirm the store's in-process write blocks until release.
Expand Down
4 changes: 2 additions & 2 deletions skills/colab-operator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ by running `uv tool install google-colab-cli` or `pip install google-colab-cli`.

## Safety
- **Always `colab stop -s <name>` when done** — idle VMs burn compute units. `colab run` (without `--keep`) self-cleans even if the script errors.
- Local state lives in `~/.config/colab-cli/sessions.json` (settings in `settings.json`, history in `history/*.jsonl`). Don't edit by hand.
- **Isolate parallel/agent runs** with the global `--config <path>` flag to point session state at a scratch file (e.g. `colab --config /tmp/agent.json new -s job`).
- Local state lives in `~/.config/colab-cli/sessions.json`, plus `sessions.attached.json` beside it for sessions attached to a runtime the CLI did not create, with their tokens (settings in `settings.json`, history in `history/*.jsonl`). Don't edit by hand.
- **Isolate parallel/agent runs** with the global `--config <path>` flag to point session state at a scratch file (e.g. `colab --config /tmp/agent.json new -s job`). Attached sessions then go to `<stem>.attached.json` (here `/tmp/agent.attached.json`), which holds tokens: delete it together with the scratch file.

## Recovery
- "Session not found" / 404 / 401 on exec: the backend pruned the VM. `colab exec`/`repl` detect this and clean up local state automatically — run `colab sessions` and re-create with `colab new`.
Expand Down
20 changes: 18 additions & 2 deletions src/colab_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

import os
from typing import Optional
from typing import Any, Optional

import click
import typer
Expand All @@ -24,6 +24,7 @@
from colab_cli.auth import AuthProvider
from colab_cli.common import state, setup_logging
from colab_cli.commands import session, execution, files, automation, run, ssh, utility, usage
from colab_cli.state import StoreError, resolve_store_paths


class AlphabeticalGroup(TyperGroup):
Expand All @@ -37,6 +38,14 @@ class AlphabeticalGroup(TyperGroup):
def list_commands(self, ctx: click.Context) -> list[str]:
return sorted(super().list_commands(ctx))

def invoke(self, ctx: click.Context) -> Any:
"""Reports a `StoreError` as a CLI error, not a traceback."""
try:
return super().invoke(ctx)
except StoreError as e:
typer.echo(f"[colab] Error: {e}", err=True)
raise typer.Exit(1)


app = typer.Typer(
help="Colab CLI",
Expand All @@ -59,7 +68,13 @@ def callback(
Optional[str],
typer.Option(
"--config",
help="Path to session state file (~/.config/colab-cli/sessions.json)",
help=(
"Path to session state file"
" (~/.config/colab-cli/sessions.json); attached sessions and"
" their tokens are kept beside it in <stem>.attached.json,"
" where <stem> is the path without its extension;"
" *.attached.json paths are refused"
),
),
] = None,
logtostderr: Annotated[
Expand All @@ -84,6 +99,7 @@ def callback(
state.config_path = config
state.logtostderr = logtostderr
state.auth_provider = auth
resolve_store_paths(config)
setup_logging(logtostderr)

# Daily fetch + cached banner on every invocation.
Expand Down
12 changes: 10 additions & 2 deletions src/colab_cli/commands/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
shape_display_label,
)
from colab_cli.utils import get_status_code
from colab_cli.state import SessionState
from colab_cli.state import DuplicateSessionError, SessionState
from colab_cli.runtime import ColabRuntime


Expand Down Expand Up @@ -360,7 +360,15 @@ def stop(
from colab_cli.common import state

name = state.resolve_session(session)
s = state.get_session(name, ignore_missing_session=True)
try:
s = state.get_session(name, ignore_missing_session=True)
except DuplicateSessionError:
state.store.remove_attached(name)
typer.echo(
f"[colab] Forgot the attached record for '{name}';"
" its runtime keeps running."
)
return
if not s:
typer.echo(f"[colab] Session '{name}' not found.")
return
Expand Down
Loading
Loading