cmux-host: a person's Claude and Codex logins carry to their next Cloud machine

TE
4d ago
+2127-8

Leo (2026-11-01), on signing in once per person for NEXT Cloud: "that's probably fine and then they can just click sign in to each of whatever e.g. if they have gemini and opencode ones too we can do that later".

What

The machine half of "sign in once per person". Once one of a person's Cloud machines has their own Claude or Codex login (from the one-click sign-in, #30188, or from a Mac-login copy they approved, #29771), the machine sends it to the person's saved-login store on the cmux platform. Every new machine of theirs then writes it before its first status report, so it starts already signed in. Logins are the person's own, never cmux's, CodeRouter's or a team's.

Rust only, in the cmux host run Cloud role, following the trajectory mirror's structure:

  • cloud/harness_login.rs (sans I/O): the harness table, file checks, wire shapes and sync decisions.
  • cloud/harness_login_thread.rs: the thread beside the worker, the file reader/writer and the Api over the machine's install token.
  • cloud/harness_login_tests.rs: the tests.
  • wire.rs: Env::platform_origin(). Dev is https://cmux-platform-development.debussy.workers.dev; Stg and Prod are None, which turns the feature off until the server ships there.
  • client.rs: CloudClient::forget_token().
  • cmux-server/cloud_http.rs: JsonPoster::send(method, ...). post now goes through it, with no change in behavior.

Harness table (data, keyed by a generic id)

idfile under the work user's homesent only when
claude.claude/.credentials.jsona JSON object whose claudeAiOauth is a non-empty object
codex.codex/auth.jsona non-empty tokens object (a ChatGPT login), and not auth_mode: "apikey"

Gemini or OpenCode later means one more row each. The ids follow ^[a-z][a-z0-9-]{0,31}$, and content must be 1..65536 bytes.

Machine behaviour

  • Boot. Right after bind (or a restart of a bound machine), the worker hands the thread its client. It then waits at most 10 s (BOOT_WAIT) for one GET /v1/harness-credentials/machine and the writes that GET allows, and only then sends its first status report. A saved login is written when the machine has no file, or when the local file is still the one last synced and the saved revision is newer. The write is atomic (temp file in the same folder, fsync, rename, folder fsync), owned by the work user (the thread's file access runs as that user via setfsuid, like the trajectory thread), file 0600, and a missing folder is made 0700.
  • Detect. Every 10 s the thread stats each login file (metadata only) and reads it only when it changed. A valid login whose fingerprint differs from the last synced one is sent once with PUT .../machine/{harness}, baseRevision = the last synced revision. The 200 answer's revision and fingerprint are recorded. Each fingerprint gets at most one final answer, so a boot write is never echoed back.
  • Converge. A GET every 5 min (±20% jitter, backoff from 5 s to 10 min on errors). A newer active revision is written only while the local file is unchanged since the last sync. A local change wins and is sent instead.
  • Revoked. When the GET shows revoked at revision R, the machine records it, keeps the local file and does not move the base. A 409 harness_credential.revoked stops sending that harness. Once the file is seen absent (signed out), the base moves to the newest revision seen, so a fresh sign-in afterwards is sent on that base and accepted.
  • 404 means the feature is off: nothing is sent, and the thread asks again hourly. 503/429 means retry with backoff.
  • State. /var/lib/cmux/harness-login-sync.json is root's, 0600. It holds only revisions, fingerprints and flags, never a login.
  • Logs name only the harness id, revision and outcome. Debug on Local, Active and Report prints byte counts only, never a login or its fingerprint.

Only a personal machine, and never a CodeRouter login

  • The thread acts only when /etc/cmux/vm-kind reads owner and there is no team enroll or binding on disk. It checks this on every pass and fails closed when the file is missing or unreadable. This is the same gate the image's cmux_agent_own_login_allowed and #30188's sign-in use.
  • How CodeRouter signs agents in on a machine (agent-auth.sh, agent-config.sh): through env (ANTHROPIC_BASE_URL/OPENAI_BASE_URL plus the public placeholder key cmux-vm-edge-placeholder, which the machine's edge swaps), Codex's generated config.toml provider, and, on a shared machine, isolated homes (~/.config/cmux/claude-team, codex-team) set through CLAUDE_CONFIG_DIR/CODEX_HOME. It never writes the CLIs' own login files. So:
    • Only the CLIs' default homes are read. CLAUDE_CONFIG_DIR/CODEX_HOME are deliberately not honoured: the image sets them for agents only to point a shared machine's CodeRouter launch at the team homes, and those must never be sent.
    • A file with the placeholder key or a crt_ route token anywhere in it is refused (Refusal::CodeRouter).
    • A Codex API-key-only file is refused (Refusal::ApiKeyOnly), and so is anything that is not the harness's own OAuth sign-in.
    • A refused file is never sent and never overwritten by a saved login.

First-chat window

On a machine whose person already has a saved login, the login file is written before the machine's first cloud.vm.status.report. A chat that starts after the machine has reported in finds the login.

What is left is the boot pass itself: one token exchange plus one GET round trip after bind, normally well under a second, capped at 10 s before the report goes anyway. A chat started on the machine within that window, before the report, could still miss the login. If the platform takes longer than 10 s, the write follows when the GET answers (at most 20 s, the HTTP client's timeout). After that, failed passes retry with backoff starting at 5 s.

Tests (cloud/harness_login_tests.rs)

  • Pure decisions: boot write, no echo, sent once, a retry resent and a refusal not, rotation converge, local edit wins, a matching login adopted, revoked keeps the file and base, 409 stops, sign-out then fresh sign-in on the newest base, a deletion seen with no local login.
  • Checks: the table and id shape, Claude/Codex validity, API-key-only, CodeRouter placeholder and route tokens (nested too), the team homes never in the table, personal-machine detection from files, Stg/Prod have no origin.
  • Wire: list parsing (unknown harness, bad fingerprint, missing content, odd state skipped), put parsing (200, 409 revoked, other 409, 404, 503, malformed 200), PUT body shape.
  • Files and a fake platform: a new machine starts signed in (0600 file, 0700 folder, 0600 state with no login in it, no temp file left, no echo after a restart); a sign-in on machine A reaches machine B, and a rotation on B reaches A; the deleted, 409, sign-out, fresh sign-in sequence end to end; a 404 deployment; invalid files neither sent nor overwritten; a symlinked login file never followed; the writer's modes and atomic replace.

Fleet: cargo fmt --check, cargo test -p cmux-host, and cargo clippy -p cmux-host -p cmux-server --all-targets -D warnings through cmux-ci test --setup rust (cmux-ci job b023db8bd3ffe1535bbb04a2, linux-pool, SUCCESS at this head; 100 cmux-host lib tests pass).

Overlap and risk

  • #28947 and #28960 (held integration PRs) touch the same role.rs/client.rs/mod.rs/cloud_http.rs, for unrelated features. This PR's edits there are additive: a field, one call in start_live, one client method, one HTTP method.
  • #30188's files are untouched.
  • Risk: this moves credentials, so it runs only in development, only on an owner machine, only for the person's own OAuth logins, with the person's install token. It reads and writes files only as the work user and never through a link.

Agent session: agent session

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Gives every new Cloud machine of a person their own Claude or Codex login before its first status report, once any of their machines has one.

  • A personal machine sends its own OAuth login (.claude/.credentials.json, .codex/auth.json) to the person's store on the platform; each new machine writes the saved login before its first status report, so the first chat finds it.
  • Runs in development only (Env::platform_origin; Stg and Prod are off). CodeRouter logins — including any file with a route token anywhere in it — API-key-only Codex files, revoked credentials and team machines are never sent, and a CodeRouter or API-key file is never overwritten.
  • Logins are read and written as the work user, and each folder below the home opens with O_NOFOLLOW, so a linked file or folder is never followed. The root-owned state file holds only revisions and fingerprints, and logs never contain a login.

Sync rules

  • A saved login is written only while the local file is still the one read, re-verified right before the rename; a login changed or refreshed in between wins and is sent instead, and saved logins from the platform must pass the same checks before writing. Each fingerprint is sent at most once.
  • A server-side deletion keeps the local file and stops sends until the person signs out locally and signs in again; an absent local file moves the base so a fresh sign-in lands.
  • Boot and rotation passes are covered by unit tests against a fake platform, including file modes, atomic writes, and an end-to-end sign-in on one machine reaching another.