acpmux: run an agent CLI's own sign-in on a Cloud machine for the chat's sign-in card

Merged
TE
4d ago
+1714-2

Leo, 2026-11-01, on building the one-click sign-in for Cloud machines now: "yes those are important".

In production cmux must not copy the Mac's Claude or Codex login to a Cloud machine (it may be against the agents' terms), and CodeRouter never serves production users. So the first Cloud chat on a machine with no login needs a sign-in that runs the agent's own login on that machine. This PR adds the acpmux side (Rust only); the transcript card is a separate webview PR built against this contract.

Contract (as built)

ACP extension methods on acpmux's JSON-RPC:

  1. _acpmux/agent_login/start {agent: "claude"|"codex", sessionId?} -> {loginId, url, userCode?, needsCode, expiresAtMs}
    • claude: runs claude auth login --claudeai, needsCode: true. codex: runs codex login --device-auth, needsCode: false, userCode set.
    • The link counts only when it is an https URL on an allowed host (claude.com, claude.ai, platform.claude.com, console.anthropic.com, auth.anthropic.com, auth.openai.com): no user part, no port, ASCII only, OSC-8 and ANSI stripped first. The read is bounded (20 s, 64 KiB kept; the rest is drained so the CLI never blocks).
    • One sign-in per agent: a new start stops the previous one (or answers busy while that one's finish is writing the login). The CLI runs in its own process group in a private 0700 temp folder, gets kill_on_drop, has PR_SET_PDEATHSIG on Linux, and is killed 15 minutes after it starts.
  2. _acpmux/agent_login/finish {loginId, code?, sessionId?} -> {signedIn, restarted}
    • claude: writes code + "\n" to stdin (one line of printable ASCII), then waits up to 120 s. codex: waits for the device flow until the sign-in expires.
    • Signed in means exit 0 and the login now in the agent's own store, read the way agent-auth.sh reads it, never by value: <CLAUDE_CONFIG_DIR or ~/.claude>/.credentials.json has claudeAiOauth; <CODEX_HOME or ~/.codex>/auth.json has tokens or a key. On macOS, Claude's exit 0 counts by itself, because Claude keeps its login in the keychain there and acpmux never reads it.
  3. _acpmux/agent_login/cancel {loginId} -> {} (does nothing when no such sign-in is running).
  4. _acpmux/agent_login/api_key {agent, key, sessionId?} -> {saved: true, restarted}. claude: sets env.ANTHROPIC_API_KEY in the Claude home's settings.json, keeping every other key; the write is 0600 and atomic, and a file that is not a JSON object is left as it is (failed). codex: runs codex login --with-api-key with the key on stdin.

After a sign-in or saved key:

  • On a cmux Cloud image (/etc/cmux/agent-auth.sh exists), ~/.config/cmux/agent-auth/<agent> is set to own\n (0600, folder 0700), so the image's launcher uses the person's login and drops CodeRouter.
  • With sessionId, that chat's agent restarts through Hub::restart_harnesses (the same path as _acpmux/sessions/restart_harness). If the chat is between turns it restarts now (restarted: true). If it is in a turn, it restarts when the turn ends (restarted: false); the turn is never cut.

Errors: JSON-RPC error with data.reason one of unsupported_agent, shared_machine, busy, cli_missing, no_url, bad_code (a code or key that is malformed or that the CLI refused), expired, failed, unknown_login. One addition to the brief: remote_refused, for a caller that may not run a sign-in (below).

  • Team machine: refused with shared_machine when /etc/cmux/vm-kind exists and is not owner, or cannot be read (fail closed, as cmux_agent_own_login_allowed).
  • Who may call: the unix socket, the local app, and the owner's own peer daemon (vm_boundary::owner). Never a Web page, a paired device, or a peer forwarding for one. Nothing the caller sends names a program, path or env.
  • Which CLI and env: the CLI is the one acpmux starts the agent with: the claude / codex harness profile (with Codex's -c/-p globals and CODEX_PATH), else the one on the login PATH. The env is acpmux's, minus ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN, OPENAI_API_KEY, OPENAI_BASE_URL, CODEX_API_KEY, ANTHROPIC_CUSTOM_HEADERS, DISPLAY and WAYLAND_DISPLAY. It adds BROWSER=true and CMUX_CLAUDE_AUTH=own / CMUX_CODEX_AUTH=own, so the image's /usr/local/bin/claude / codex launchers add no CodeRouter config to the login run.
  • No secrets in output: nothing logs, traces, journals or answers the code, key or token. The code and key are held in Zeroizing buffers.
  • Advertised: agent_login in initialize's _meta.acpmux.features, and capability agent-login-v1 (in _meta.acpmux.capabilities and _acpmux/status).

Routing: the card names its chat on every call

sessionId is optional on every method. A request that names a chat living on a peer is forwarded whole to that peer by the existing peer_forward.rs path. So a pane connected to the Mac's acpmux reaches the Cloud machine's acpmux by passing the Cloud chat's sessionId to start, finish, cancel and api_key. Without it, the sign-in runs on the daemon the pane is connected to.

What the card PR must add (not in this PR)

The app pane's host allowlist is default-deny. The card PR must add the four methods to AcpmuxPaneMethods.requests, their params to knownParams (agent, sessionId, loginId, code, key), the methods to optionallySessionScoped, and a gesture rule for start / finish / api_key. It must make the same change in cmux-tui/crates/cmux-agent-pane-policy/policy.json. the_allowlist_is_what_the_page_sends requires that table to match what the page sends, so the allowlist ships with the card.

How a Cloud machine gets this acpmux

The VM's acpmux is its cmux-tui binary, /home/cmux/.cmux/bin/cmux-tui. Once this lands, the cmux-tui artifacts workflow publishes the tree build, including cmux-tui-x86_64-unknown-linux-musl. Every DEV, nightly or release app built from a tree that contains this commit bundles that daemon (scripts/cmux-next/pin-cmux-tui.sh tree mode).

The app's automatic machine update then installs it on the machine: CloudMachineUpdateTarget runs cmux link machine update --to-bundled --if-older. That writes the pinned Linux build beside ~/.cmux/bin/cmux-tui, swaps it in and restarts only the daemon, so chats and terminals keep running and a failed update rolls back. Machines too old for that get the same build through the Cloud API exec instead (CloudLegacyMachineUpdate). A local source build with no payload manifest cannot update machines.

Tests

  • Unit tests (agent_login/tests.rs):
    • link parsing from the exact claude auth login output, both plain pipes and under a terminal (OSC-8 and colours)
    • a link that is still arriving is not offered
    • Codex's link and one-time code from its ANSI output, and the code's shape
    • allowlist refusals (http, lookalike host, user part, port, backslash, whitespace, non-ASCII)
    • vm-kind shared-machine detection, and refusal before anything runs
    • cli_missing
    • the own-choice file: only on an image, own\n, 0600/0700
    • settings.json merge: keys kept, 0600, no temp file left, non-object refused and left untouched
    • store checks
    • one-line codes and keys
    • model-plane env removed from the command
  • Integration test (tests/agent_login.rs), with stand-in claude and codex scripts on the harness profiles. The scripts print what the real CLIs print, fail if any model-plane value or DISPLAY reaches them, and read the code or key from stdin. Over a real connection it covers:
    • initialize advertises the feature and capability
    • unsupported_agent
    • Claude start, then a wrong code (bad_code, after which the login is gone: unknown_login)
    • a new start replaces an older one
    • Claude finish with the right code signs in
    • cancel, including a second cancel
    • Codex device flow, plus the own-choice file on an owner Cloud image
    • Codex and Claude API keys, including a refused key
    • shared_machine on a team machine
    • a Web connection is refused
  • cmux-ci test: cargo test -p acpmux --lib -- agent_login capabilities protocol, cargo test -p acpmux --test agent_login, cargo clippy -p acpmux --all-targets -- -D warnings and cargo test -p cmux-agent-pane-policy.

Risk

The change touches credentials. Mitigations:

  • The sign-in runs only for the person's own clients, and is refused on team machines.
  • Only allowlisted https links are answered.
  • Codes and keys never reach logs or replies.
  • The CLI never sees model-plane values.

It adds no Swift and no new trust path. Peer forwarding is the existing session-forwarding path, and the receiving daemon applies its own origin check.

Agent session: agent session

🤖 Generated with Claude Code