acpmux: pull the person's vault env onto a joined machine

Merged
TE
3d ago
+3002-16

Leo, on setting up a Mac mini: "yeah i feel like there's too many commands and clicks to deal with all that tbh". The hard part was "transferring stuff e.g. environments keys and whatnot". The goal is to sign in once and have every machine you add get your API keys and env vars.

What this adds

acpmux machine vault pull|status|forget (also acpmux host vault ...). A machine joined with machine join uses the machine credential it already stores (cli/cloud_join.rs) to read the person's vault from GET {hub}/v1/vault/machine. Every agent the daemon starts gets those env vars. The command advertises machine.vault.pull.v1 in acpmux capabilities. It is CLI-only and adds no wire method, the same as machine.join.stdin.v1.

  • Wire (vault/wire.rs): reads { env: [{ name, revision, state, valueBase64? }] }. A 403 with vault.not_granted means the machine has no consent; this is not an error. A 401 means the credential is revoked; a 404 means the hub has no vault; 429/5xx are retried. A harnessCredentials list is never decoded. Names follow the machine.env rules: no loader, shell-bootstrap, PATH/HOME, CMUX_ or ACPMUX_ names. CLAUDE_CODE_OAUTH_TOKEN is refused too.
  • Storage (vault/store.rs): each value goes into the OS secret store and never a file. On macOS that is the Keychain (service cmux-vault, account env/<NAME>, through security -i on stdin, reusing harness_secret::store_item/run_store). On Linux it is secret-tool. Values are base64-encoded inside the item, so any UTF-8 value fits, multi-line ones included. A machine with neither store refuses with vault.secret_store_unavailable and writes nothing.
  • State (vault/plan.rs): <ACPMUX_HOME>/vault-state.json (0600) holds the hub, pulledAt, lastResult, and per entry its name, revision and a fingerprint salted per machine. It never holds a value. The plan itself does no I/O. A new or changed entry is stored. An entry at the same revision with the same fingerprint is kept. A revoked entry, or one no longer listed, is deleted from the store. When the vault is no longer granted, or the credential is revoked, every entry is removed.
  • Daemon (vault.rs, started from hub/startup.rs): it pulls at start when the machine is joined, then every 15 minutes. After a failure it backs off from 30 s up to 15 min. Logs carry codes and names only. Values stay in memory and are reloaded from the store only when the state file changes, so a CLI pull reaches the next spawn. A daemon pull and a CLI pull share one flock.
  • Precedence (login_env::overlay, used by pairs_for), high to low: profile/preset/session env, then the machine's own delivered machine.env, then the vault, then the daemon's own environment, then the login shell's. The nested-Claude scrub puts vault ANTHROPIC_* keys back the same way it does for machine.env. Remote-origin launches (apply_remote_tokio) and ${env:…} references never see vault values.
  • Subscription logins are not carried. The vault never stores, copies or writes Claude or ChatGPT/Codex OAuth or session logins. machine vault status only reports whether each harness is signed in on this machine. For Claude that means .credentials.json exists, or on macOS that the Claude Code-credentials Keychain item exists; only its attributes are checked and the token is never read. For Codex it means auth.json exists. Status then names the harness's own sign-in step (claude then /login, codex login).

pull --json prints one object on stdout. {"state":"ok","env":[names…]} and {"state":"not_granted"} both exit 0, as does "off". A pull or forget that could not store or delete every entry prints its outcome with failed on stdout. Its state is "partial" in place of "ok", or it keeps not_granted/partial for forget. It exits 1 with detail vault.partial, and the changes that did succeed are kept. Every other result exits 1 with the standard error envelope and a vault.* detail.

Review fixes: a revoked or removed entry leaves the delivered set at once; when its store delete fails it keeps only a pendingDelete cleanup record, which every pull retries. The pull lock covers reading the credential, the request and the save, and an answer for a replaced or revoked credential is dropped. An unreachable store is a delete error. A load that could not read every entry is retried after 30 s. The store command's output is read while it runs. The state is bound to a salted fingerprint of the credential it was pulled with. Settle re-checks the credential after its store writes and publishes nothing for a replaced or revoked one, and the daemon gives agents values only while that credential is live. Without a secret store, removals still leave the delivered set. Items carry a v1: marker before the base64, so empty values are stored. No field ever holds a value.

Tests

The tests in vault/tests.rs cover:

  • wire parsing: refused names, newest-revision-wins and harnessCredentials ignored
  • the 401/403/404/5xx mapping
  • plan decisions: keep, store, delete, wipe
  • salted fingerprints
  • a state file that is 0600 and holds no value
  • refusal when there is no secret store
  • removal on a revoked credential, and forget
  • the daemon loading only values that match their fingerprints
  • env precedence
  • backoff
  • a loopback fake hub answering 200, 403 and 401, with the bearer header checked
  • a check that no Debug, JSON, state or error text contains a value or its base64

Overlap: this does not touch #26487 (cmux vault, Swift) and shares no names with it. It builds on #30502's machine.env overlay and #27216/#27428's machine credential.

Agent session: agent session

🤖 Generated with Claude Code