Agent harness credentials
Zimmer keeps a pool of vendor accounts and rotates between them when one hits its rate limit. That’s the feature. Underneath it is the most fragile machinery in the project, and it’s fragile for a reason that isn’t Zimmer’s fault.
The model
Section titled “The model”ClaudeAccount is the pool for both runtimes, discriminated by a runtime column
(claude_code | codex). The naming is a leftover.
Everything goes through RuntimeAuthProvider.for(runtime) → ClaudeAuthProvider or
CodexAuthProvider.
| Claude | Codex | |
|---|---|---|
| Token endpoint | platform.claude.com/v1/oauth/token | auth.openai.com/oauth/token |
| Client ID | 9d1c250a-e61b-44d9-88ed-5944d1962f5e (the CLI’s public ID) | app_EMoamEEZ73f0CkXaXp7hrann |
| Files | ~/.claude.json (identity), ~/.claude/.credentials.json (tokens) | ~/.codex/auth.json |
| Token TTL | from expiresAt (~8h, inferred) | 24h, inferred — auth.json has no expiry field |
| Rotation | AccountRotationService, 5-minute interval | inline in the provider, 24h |
| Identity check on capture | email must match | none |
The refresh loop
Section titled “The refresh loop”RefreshRuntimeAuthTokensJob runs every 5 minutes:
The refresh threshold is 15 minutes on a 5-minute cron — three chances to catch a token before it expires.
Refresh tokens are single-use and rotating
Section titled “Refresh tokens are single-use and rotating”Every refresh returns a new refresh token and invalidates the old one — and invalidates the sibling access token. Two consequences the code has to defend against:
- The new pair must be persisted atomically. A crash between “got new tokens” and “wrote them”
bricks the account.
refresh_token!writes both in oneupdate!. - A future
expiresAtis not proof a token is live. If someone else refreshed, your still-unexpired access token is already dead. The code does not enforce this —token_expired?still keys purely offexpiresAt. The defense is the completeness invariant.
A credential set with no refresh token is a dead end. ClaudeAccount.complete_claude_oauth?
refuses to persist or adopt one, because the CLI sometimes rewrites .credentials.json without the
claudeAiOauth block at all, and adopting that blindly would brick the whole pool.
The credentials-owner marker
Section titled “The credentials-owner marker”Here is the structural problem the marker solves.
In the deployment shape this code was written for, ~/.claude.json (identity) is container-local
while ~/.claude/.credentials.json (tokens) is a shared bind-mount. So any code that reads the
local identity file to decide who owns the shared tokens gets a confidently wrong answer on the
wrong container. That is the root cause of the 2026-06-11 cross-account token-contamination outage.
The fix: a marker file, ~/.claude/.ao-credentials-owner.json, written next to the shared tokens,
recording which account they belong to. filesystem_credentials_owned_by_self? gates the sync.
Rotation on quota
Section titled “Rotation on quota”When an account hits its rate limit, Zimmer rotates to the next one by priority:
- Sync the outgoing account’s tokens off disk.
- Snapshot its quota state.
mark_quota_exceeded!.activate_next_account— which validates the candidate by callingrefresh_token!before activating it, so a broken account is skipped before it can brick the pool.- Write the new account’s config and credentials to disk, stamp the owner marker.
- Record an
AccountRotationEvent.
QuotaResetCheckerJob (every 15 min, Claude only) restores quota_exceeded accounts when either
window’s reset time has passed, or utilization drops below 100%.
Mid-run auth loss
Section titled “Mid-run auth loss”AuthRecoveryService watches the transcript for "Not logged in · Please run /login" (matched by
/not logged in|please run\s*\/login/i), re-injects credentials to disk, and re-spawns the process.
Bounded by MAX_RECOVERY_ATTEMPTS; returns :unrecoverable if no account is available.
Logging in from the UI
Section titled “Logging in from the UI”The “Authenticate” button drives a PTY-screen-scraping flow:
RuntimeLoginAttempt is being used as a cross-container message bus: the web process writes
pasted_code, the worker process reads it. That’s why poll_state must use
RuntimeLoginAttempt.uncached — ActiveRecord’s per-request query cache would otherwise hide the
write. It’s documented at length in the code, and it’s a landmine.