acpmux: run an agent CLI's own sign-in on a Cloud machine for the chat's sign-in card
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:
_acpmux/agent_login/start {agent: "claude"|"codex", sessionId?}->{loginId, url, userCode?, needsCode, expiresAtMs}- claude: runs
claude auth login --claudeai,needsCode: true. codex: runscodex login --device-auth,needsCode: false,userCodeset. - The link counts only when it is an
httpsURL 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
busywhile that one's finish is writing the login). The CLI runs in its own process group in a private 0700 temp folder, getskill_on_drop, hasPR_SET_PDEATHSIGon Linux, and is killed 15 minutes after it starts.
- claude: runs
_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.shreads it, never by value:<CLAUDE_CONFIG_DIR or ~/.claude>/.credentials.jsonhasclaudeAiOauth;<CODEX_HOME or ~/.codex>/auth.jsonhas 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.
- claude: writes
_acpmux/agent_login/cancel {loginId}->{}(does nothing when no such sign-in is running)._acpmux/agent_login/api_key {agent, key, sessionId?}->{saved: true, restarted}. claude: setsenv.ANTHROPIC_API_KEYin the Claude home'ssettings.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: runscodex login --with-api-keywith the key on stdin.
After a sign-in or saved key:
- On a cmux Cloud image (
/etc/cmux/agent-auth.shexists),~/.config/cmux/agent-auth/<agent>is set toown\n(0600, folder 0700), so the image's launcher uses the person's login and drops CodeRouter. - With
sessionId, that chat's agent restarts throughHub::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_machinewhen/etc/cmux/vm-kindexists and is notowner, or cannot be read (fail closed, ascmux_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/codexharness profile (with Codex's-c/-pglobals andCODEX_PATH), else the one on the login PATH. The env is acpmux's, minusANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_BASE_URL,CLAUDE_CODE_OAUTH_TOKEN,OPENAI_API_KEY,OPENAI_BASE_URL,CODEX_API_KEY,ANTHROPIC_CUSTOM_HEADERS,DISPLAYandWAYLAND_DISPLAY. It addsBROWSER=trueandCMUX_CLAUDE_AUTH=own/CMUX_CODEX_AUTH=own, so the image's/usr/local/bin/claude/codexlaunchers 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
Zeroizingbuffers. - Advertised:
agent_loginininitialize's_meta.acpmux.features, and capabilityagent-login-v1(in_meta.acpmux.capabilitiesand_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 loginoutput, 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-kindshared-machine detection, and refusal before anything runscli_missing- the own-choice file: only on an image,
own\n, 0600/0700 settings.jsonmerge: 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
- link parsing from the exact
- Integration test (
tests/agent_login.rs), with stand-inclaudeandcodexscripts on the harness profiles. The scripts print what the real CLIs print, fail if any model-plane value orDISPLAYreaches them, and read the code or key from stdin. Over a real connection it covers:initializeadvertises the feature and capabilityunsupported_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_machineon 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 warningsandcargo 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