Credential management
August 31, 2026 · View on GitHub
Status: design (target state). Companion to
secrets-trust-boundaries.md.
The problem
Every agent harness authenticates with a rotating, interactive OAuth login
stored on disk (Claude Code's .claude/.credentials.json, .codex/auth.json,
.grok/auth.json, .kimi-code/…, antigravity's Google token, droid's encrypted
blob). Measured on a live box, all of them are the same shape: a short-lived access
token plus a single-use refresh token that rotates server-side on every refresh.
Two failures follow from treating that login as fleet state:
- Fleet-wide logout.
agents fleet applycopies the login file across machines (FLEET_AUTH_FILES). When one box refreshes, the server rotates the refresh token and invalidates every other copy — the whole fleet drops to "run /login" (the codebase already documents this:fleet/remote-login.ts— "droid collapsed 10 boxes → 1 overnight"). - Touch ID storm. On macOS the login lives in the login Keychain, ACL-bound to
the harness (Claude Code, etc.). agents-cli isn't on that ACL, so every time it
reads the token — to draw
ag viewusage bars, to select an account — macOS pops a Touch ID sheet.
Both come from the same mistake: agents-cli touching the interactive login.
The invariants (non-negotiable)
-
The daemon holds no token. No fallback, no injected, no per-account token. It never reuses a live/interactive token, never refreshes or rotates one, never logs in for the user. A routine runs the exact same
agents runprocess a user runs directly. (Shipped: PR #1583.) Seeroutines.md. -
The interactive/rotating login is untouchable. agents-cli never reads, stores, syncs, or references a harness's interactive OAuth login. Not for usage, not for fleet sync, not for account selection. It is never written to the keychain by us and never copied across devices. It stays on the box that minted it and refreshes itself there. Enforced on the transfer paths too (RUSH-2527): neither
agents run --host --copy-credsnoragents run --leaseserializes a native login (Claude OAuth + codex/grok/geminiauth.json) to another device — both refuse and steer to a portable provider account, sharing the oneisNativeOAuthRuntimepredicate (src/lib/hosts/credentials.ts→buildHostCredentialScript,src/lib/crabbox/runtimes.ts→buildCredentialScript; SING-1b). Portable account bundles still cross the fleet through the explicitagents accounts syncpath. -
The only credential agents-cli manages is a deliberate, durable credential. A long-lived, non-rotating OAuth setup-token / API key / bearer token (
claude setup-token,OPENAI_API_KEY,XAI_API_KEY,FACTORY_API_KEY, …). Valid until explicitly revoked → safe to reuse on many devices, no repeated logouts, no revocation cascade. That safety property is the whole reason it, and only it, is shareable. -
Shipped (RUSH-2470): each provider account is its own named
agents secretsbundle, not one reserved bundle.agents accounts add <name> --provider <p> --auth <type>creates a bundle named after the account, with secrets policyneverset unconditionally — never the OS keychain's biometry ACL, so reading it raises no Touch ID prompt. There is no shared "auth" bundle name; a user can hold as many named accounts as they need, and only the accounts they explicitlyagents accounts sync <name> --device <device>cross the fleet. That sync (and everyagents secretstransport that moves credential bytes) rides a hardened SSH posture (RUSH-2527): the destination is verified against the CLI-managed known_hosts store — a changed host key is refused — and the credential connection is never multiplexed, so it leaves no reusable authenticated control master behind. Secret bytes cross on ssh stdin (push) / stdout (resolve), never on argv. -
Usage probes read the setup-token, not the interactive login — and never a prompting keychain read. Caveat (RUSH-2392): Anthropic's
claude setup-tokenis scopeduser:inferenceonly; the usage endpoint requiresuser:profile, so a setup-token cannot populate bars through that endpoint. Interactive Claude sessions populate the same per-account cache without exposing a credential: Claude Code sends its native five-hour and seven-day rate limits to the managed status-line command after responses.agents viewreads those snapshots. A headless account that has not produced a native snapshot still rendersusage unavailable (headless).Claude is event-fed, not polled. The managed Claude settings command is
agents __claude-statusline. Resource sync merges that command into each version home's existingsettings.json, preserving every other setting and delegating a prior custom status-line command. Claude Code invokes it withrate_limitsonly after a real inference response; launching Claude without receiving a response can therefore show host/model while leaving quota unchanged. The five-hour and seven-day fields may arrive independently, so ingestion merges each window into the last snapshot instead of replacing the other one.agents view claudealways reserves bothSandWslots: a provider-omitted window is a filled redunavailableslot, distinct from a real zero-percent window. Do not restore/api/oauth/usagepolling or read/copy the interactive OAuth credential to fill these bars. -
Zero Touch ID —
ag view, agent launch, usage, any op — across every harness, including the hard ones (Droid, Kimi). Solution decided per credential type, not per agent name.
One account namespace: provider credentials and named native logins (RUSH-2527)
An account is one authorization identity, and it comes in two kinds that share
a single name namespace (meta.accounts):
- Provider credential accounts — a durable API key, setup token, or bearer
token the CLI stores as a policy-
neversecrets bundle (invariant 4 above). Created withagents accounts add; portable, soaccounts synccopies it. - Native account records — a durable name for a harness's own signed-in
login.
agents accounts name <source> <name>(e.g.agents accounts name claude@2.1.220 work) records metadata only — a stable id, the harness, the identity key, and a friendly label — inmeta.accounts.native. The harness-owned OAuth/session credential is never copied, so a native account cannot besynced. A native lookup reads onlymeta, never the provider bundle store or the keychain.
Only a safely-identifiable native login is nameable/attachable.
account-capabilities.ts is the canonical table, and it is deliberately
conservative — a NativeAccount stores no device-id discriminator, so a login
whose identity can't be proven unique across synced metadata is marked
unsupported rather than falsely supported:
| Harness | Native account naming |
|---|---|
| Claude, Codex, Grok | supported — version-scoped, strong account key; attach to an exact agent@version |
| Muse | conditional — version-scoped, email-only; nameable only when the login exposes an email |
| Antigravity, Kimi, Droid, OpenCode | unsupported — device-scoped but opaque/singleton; the identity can't be proven distinct across devices (Droid exposes no account key; Antigravity/OpenCode can alias two credentials as one) |
| Cursor | unsupported (blocked) — multi-account isolation unresolved; use its API-key provider account instead |
| everything else | unsupported / discovery-only |
agents accounts name/attach refuse an unsupported harness with a named
reason (for example, kimi accounts can't be isolated by agents-cli yet (device-scoped login). Supported today: claude, codex, grok.). That gate
applies only to native naming/attachment. Provider accounts add <name> --provider <p> stays unrestricted. For a supported (version-scoped) login,
attach validates the target is currently signed in to the same identity
before binding, and injects no secret or env.
The commands read like the task, object first:
| Command | Behavior |
|---|---|
agents accounts / list | Unified list: provider account bundles + named native logins |
agents accounts name <agent@version> <name> | Name a signed-in native installation (refuses unsupported harnesses) |
agents accounts add <name> --provider <p> --auth <t> | Store a provider credential account |
agents accounts view <account> (alias inspect) | Show one account — kind, custody, and its attachments |
agents accounts attach <account> <target> | Bind an account to a target. A native account attaches only to a supported agent@version installation. A provider account attaches to an agent@version, a bare harness id, or an existing custom-harness profile. Typos and unsupported targets are rejected before binding. |
agents accounts detach <account> <target> | Remove one attachment |
agents accounts rename <old> <new> / remove <name> | Rename or remove either kind; remove refuses while a binding, a per-harness default, or a harness profile still references the account |
agents accounts switch <harness> [account] | Fast picker (or direct name) that writes the per-harness default. --json lists or reports. Same binding as set-default. |
agents accounts sync <account> <device> | Copy a provider account bundle to a worker (native records have no bytes to copy) |
Account registration is uncapped. (The plan-tier cap that briefly shipped in 1.22.42-1.22.43 read the billing tier from the Rush/Prix backend; it was removed with the rest of that coupling pending the Phoenix-backed account layer, RUSH-2581.)
set-default / clear-default remain the per-harness-default spelling and are
consulted after an exact agent@version or device-scoped binding.
agents accounts switch <harness> (optional [account], --json) is the fast
picker over that same default: it lists named accounts with usage / headroom /
signed-out state and writes set-default. No extra persistent state.
resolveAccountSelection orders resolution: explicit --account → exact target
binding → device-scoped binding → per-harness default. Runtime injection of the
resolved account (live-fingerprint validation for native, env for provider) and
the fleet inventory labels are wired by the runtime/fleet-auth track; fleet
credential transport is owned by the credential-transport track.
What is "held" and shared (the ingredients)
| ingredient | where | shared across fleet? | why safe |
|---|---|---|---|
| Interactive OAuth login | the box that minted it, in its own config home / the harness's own keychain item | No — never touched by us | rotates/revokes on cross-use; leaving it alone is the fix |
| Setup-token / API key (durable) | a named agents accounts add bundle, secrets policy never | Yes — synced, explicitly | non-rotating, revoke-only; reuse never invalidates another holder |
| daemon / CLI | — | — | hold nothing |
The only thing that crosses the fleet is a provider account bundle the user
deliberately created with agents accounts add and explicitly pushed with
agents accounts sync <name> --device <device>. Nothing rotating is ever copied.
How each surface changes
agents fleet applydoes not copy login files.FLEET_AUTH_FILESis inventory metadata only; fleet apply has no native-login materialization path. Per agent per boxapplysurfaces: "logged in" / "log in on this box" (interactive oragents fleet login) / "add or sync a provider account (agents accounts add/sync)" — driven by whether the box has its own login or a declared account bundle, never by agent identity.agents fleet login(per-box device-code over SSH, writes the credential on the box, never transports it) stays as the per-machine login path. Onboarding a new device syncs the needed provider account bundle (agents accounts sync) instead of copying logins.ag view/ usage (usage.ts) reads the shared per-account usage cache. Interactive Claude sessions feed that cache through Claude Code's native status-line payload; explicit network probes read the setup-token from its named account bundle. Neither path reads the harness's ACL-bound keychain login. No no-ACL cache of the interactive token is needed because the interactive token is never read. A headed daemon publishes those non-secret rows to its per-devicedaemon-state.jsonin the fleet-synced user repo. The daemon automatically commits only its owned file and runs a serialized, 45-second-bounded Git exchange; workers consume the delivered local mirror newest-wins, with no per-tick device-to-device SSH mesh. Claude's human row ends with one unlabeled last-active timestamp. Auth-health remains available in--jsonfor machine consumers; it is not rendered as a second timestamp beside usage because that probe age is neither activity age nor usage-capture age. When Anthropic returns 403user:profileon that token, the probe setsreason: 'usage_scope'so auth-health staysunverified(notrevoked) and the row showsusage unavailable (headless)(RUSH-2392).- Routines /
agents runauthenticate via the box's own login (interactive) or the setup-token the user placed; the daemon injects nothing.
Per-harness credential map (evidence-based, verified)
macOS keychain-ACL (→ Touch ID when we read it) is claude + antigravity only
(auth-sync.ts:47). Every other harness reads its login from a plain file and
never triggers Touch ID (usage.ts per-provider reads). Setup-token env vars
are already mapped in profiles.ts:324-329 for BYOK profiles.
| harness | macOS login store | setup-token / API-key env var | wired in agents-cli? |
|---|---|---|---|
| claude | keychain-ACL | CLAUDE_CODE_OAUTH_TOKEN (claude setup-token, 1yr) / ANTHROPIC_API_KEY | daemon-inject removed (PR1); ANTHROPIC_AUTH_TOKEN via profiles; Linux shim reads .oauth_token |
| codex | file (.codex/auth.json) | OPENAI_API_KEY | yes (profiles.ts:326) |
| gemini | file | GEMINI_API_KEY | yes (profiles.ts:327) |
| grok | file | XAI_API_KEY | yes (profiles.ts:328) |
| opencode | file | OPENCODE_API_KEY | yes (profiles.ts:329) |
| droid | file (locally-decrypted, no keychain) | FACTORY_API_KEY (fk-…) | no — unwired anywhere |
| kimi | file (.kimi-code/…) | none — Kimi reads only config.toml, not env | no (not possible via env) |
| antigravity | keychain-ACL | ANTIGRAVITY_API_KEY (agents-cli claims; upstream issue #78 says unsupported — unresolved) | preset only |
Resolved open items:
- Touch ID is Claude-only in practice. Only claude routes usage/probe through
the ACL keychain (
usage.ts:1305-1306,loadClaudeOauth→getKeychainToken). Antigravity is keychain-bound but has NO usage read, so it doesn't hit theag viewstorm. Droid & Kimi are already file-based → no Touch ID to fix. - Kimi has no env-var auth (config.toml only) — a real limitation; its file-based OAuth login stays per-box, no shareable token.
- Droid:
FACTORY_API_KEYis real but agents-cli wires nothing — a gap to close if we want droid selectable as anagents accountsprovider.
Which credential a run injects — keyed on DEVICE ROLE, not run mode (PHNX-3502)
The map above says which credential types exist. This says which one a given
agents run actually authenticates with — the rule that governs
buildExecEnv → the harness adapter's applyExecConfigEnv
(harness/adapters/claude.ts).
A harness has two credentials, and a box carries at most one of them:
- Native interactive login (
.claude/.credentials.json, scopeuser:profile, behind Claude Code's Touch-ID/keychain-ACL item). A headed box holds this —personal/desktop(isHeadedDeviceRole,device-config.ts), the seat a human actually logged in at (e.g.zion). It is untouchable per the invariants above. - Setup-token (the
authbundle →.oauth_token→CLAUDE_CODE_OAUTH_TOKEN, scopeuser:inference). This is the worker credential — a non-interactive, non-rotating, 1-year OAuth token for runs with no human present. A worker device carries this and no native login.
The routing rule is one predicate — isHeadedDeviceRole(ctx.deviceRole), NOT
ctx.interactive:
| run | worker device | headed (personal/desktop) |
|---|---|---|
| interactive TUI | inject setup-token | defer to native login |
| headless one-shot | inject setup-token | defer to native login |
"Interactive" means "this opens a TUI," not "a human with a keychain login is
present." An interactive run on a worker is a remotely dispatched TUI
(agents run claude --interactive --device <worker>), not someone sitting at that
box — so it authenticates with the same setup-token headless runs use. Symmetrically,
a headless one-shot on the user's own personal laptop (agents run claude "fix the bug") MUST use that box's native login, never the setup-token (RUSH-2395 — gating
on ctx.interactive alone hijacked the laptop's login onto the setup-token).
Two bugs this rule closes, one on each side of the diagonal:
- PHNX-3502 — the old
if (ctx.interactive || headedDevice)deferred any interactive run to the native login. On a keychain-less worker there is no.credentials.jsonto defer to and the setup-token it does hold was never injected, soagents run claude --interactive --device <worker>landed on Claude Code's login/theme-picker screen with a perfectly good credential sitting unused. - RUSH-2395 — the mirror image: keying on run mode sent a headless laptop run onto the setup-token and took the human's hand-driven sessions off their login.
Keying on device role — not run mode — is the single fix for both.
Establishing the worker credential (non-interactive login)
The setup-token is not a file you hand-copy; a worker gets one by minting it —
agents auth mint claude (alias agents accounts mint), which drives claude setup-token through its device-code OAuth flow and seeds the result as a named
account (driveSetupTokenMint, auth-mint.ts, PHNX-2364).
The authorize step still needs a browser pointed at the right account: the fleet's
logins accumulate in browser profiles (agents browser profiles logins), so
minting for a specific account means authorizing in the profile signed into that
account — the profile-switch friction is real and lives here, at mint time, not at
run time. Once minted and synced (agents accounts sync <name> --device <worker>),
every run on that worker authenticates from it with zero Touch ID and no human.
The Touch ID fix (concrete)
Only the token-acquisition step changes — no endpoint/header change (the usage
endpoint takes any sk-ant-oat01- bearer, usage.ts:624,957):
- In
loadClaudeOauth(and its callersprobeClaudeStatus/getClaudeUsageInfo,usage.ts:604,938), resolveCLAUDE_CODE_OAUTH_TOKENfrom the named account bundle (or env) BEFORE the keychain-ACL read (usage.ts:1348-1353). If a setup-token is present → use it as the bearer and skipgetKeychainTokenentirely → no ACL-gated/usr/bin/securitycall → no Touch ID. - Same for the daemon's every-3-min
probeLocalFleetAuth(auth-health.ts:391-410) — the real storm source — so its warm loop reads the account bundle's setup-token, never the ACL-bound keychain login. - The setup-token lives in a bundle written by
agents accounts add, which always sets secrets policynever— a no-biometry-ACL item (keychain on macOS, the platform default elsewhere), never the harness's own ACL'd login. Populated by the user OR self-minted by the agent (claude setup-tokenvia pty + computer-use).
Migration (priority order — Touch ID first, it's the live pain)
- Daemon holds nothing (done, #1583).
- Claude usage/probe read the account bundle's setup-token, not the ACL'd
keychain login → kills the Touch ID storm. Self-mint + store the setup-token
per account. Enforcement landed:
loadClaudeOauth'saccessTokenCachepath (usage.ts) now returnsnullwhen no setup-token is provisioned instead of falling through to the interactive keychain /.credentials.json— so the daemon's usage (~60s) and auth-health (~3min) warms can never read or transmit the interactive OAuth login (the transitional fallback + its no-ACL cache are removed). An unprovisioned account reads asunconfiguredfor the probe; a normal interactive response can still populate its usage snapshot through the native status line. Rush Cloud dispatch does not read a Claude credential at all (SING-1b: the account manifest is version + email only). The leftoverreadClaudeCredentialsBlobhelper that still read Keychain /.credentials.json— the #1767 shape — is deleted (RUSH-2359).--leaseSING-1b detection reads the wrapped rotating blob itself and rejects anything that is not{ claudeAiOauth.accessToken }. applystops copying rotating login files (Gap B).- Shipped (RUSH-2470):
agents accounts add <name>creates a named, policy-neverbundle per account;agents accounts sync <name> --device <device>copies it explicitly to a worker device (encrypted file backend on Linux, Credential Manager on Windows). No reserved bundle name — every account the user creates is independently named and independently synced. - Fleet upgrade + verify zero Touch ID on a real macOS box (the proof).