Server-owned credential authority: per-run OAuth snapshots fork the token family, rotation logs everyone out (grilled 2026-07-24) #222
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/coding-lab#222
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
Since the container-isolation slices landed, the operator repeatedly hits "not logged in" — in panes, and as the host-level login banner / spawn refusals. Re-login fixes it for a few hours, then it recurs.
Root cause (diagnosed 2026-07-24 on the dev host)
InjectCredentials(#202,internal/provider/claudecode/inject.go) snapshot-copies the master.credentials.jsoninto every run's private instance HOME, with deliberately no write-back. Anthropic's OAuth rotates refresh tokens: when a long-lived instance's ~8h access token expires, its CLI refreshes its private copy, receives a new token family, and invalidates the refresh token that the master store and every other snapshot still hold. When the master's access token next expires, the host-side refresh fails →claude auth statusreads logged-out → spawn gate refuses, new snapshots are dead on arrival. Re-login mints a fresh family and the cycle restarts.Live evidence (token sha256-prefix fingerprints, not secrets):
/var/lib/lab/.claude/.credentials.jsonc6822a01/6f9f9848run_a9c8…968f977e/571a3fb5run_68753…64ff94a7/13d3372brun_11e5…Three independently-refreshed families alive at once, each rotation orphaning all other holders. Pre-#202 every session shared one host credentials file, so exactly one family ever existed — that's why this only appeared with the containerization work.
Side effect: wipe-on-stop can shred the only valid family if the newest rotation happened inside an instance — forced re-login even with nothing else running.
codexalmost certainly carries the same latent bug: same snapshot design (internal/provider/codex/inject.go), and OpenAI also rotates refresh tokens.Settled design (grilled 2026-07-24)
Invariant: one refresher per provider grant; instances are consumers, never refreshers. Lab owns the grant without speaking any provider's OAuth protocol. Five decisions from the grilling session:
InjectCredentialsseam (single call site by design; instance HOMEs are host paths even for container runs). Probe evidence below shows rotation invalidates the old family's access tokens within minutes, so fan-out must directly follow any detected master change. Copies kept fresh never near expiry → instances never self-refresh → no thundering herd.provider.AgentProvider:RefreshCredentials(ctx)— drive the provider's CLI to refresh the master store;CredentialsSig(home)— opaque change-detector over the credential file(s) under a home,""meaning the master store (the SpoolSig precedent);AdoptCredentials(instanceHome)— copy an instance's credentials back into the master store.Core owns the rotation loop; adapters own every file path and CLI recipe. Fan-out reuses
InjectCredentials.Rejected alternatives, for the record: shared credentials file (claude derives the creds path from
CLAUDE_CONFIG_DIR, which must stay per-run; tmp+rename replaces any symlink/hardlink on first refresh — the agent.sock inode lesson of ADR-0052 — and it reopens the isolation seam #202 closed); sync-back alone (all holders share one expiry clock, so the concurrent-refresh herd at T+8h is the common case); adopt-back-only-on-master-failure (leaves a window up to the remaining master token lifetime where the fleet holds a doomed family).Compat probe results (claude, probed live 2026-07-24)
expiresAtforged 60 s into the past,claude -p --model haiku(underCLAUDE_CONFIG_DIRpointed at the master) succeeded and rewrote the master store in place (23:12:42, 509 → 945 bytes) with a fresh valid family — the next spawn ran on it with no re-login.expiresAtrather than any in-memory view.claude auth statusdoes not rewrite the credentials file when the token is valid (clean control run). Whether it refreshes an expired token was not tested — moot given the confirmed trigger above.Remaining probes (codex — fold into implementation, not blocking)
codex(thecodex execanalogue ofclaude -p)?codexread auth state from disk before refreshing?References
internal/provider/claudecode/inject.go,internal/provider/claudecode/auth.go,internal/provider/codex/inject.goServer-owned credential authority: per-run OAuth snapshots fork the token family, rotation logs everyone outto Server-owned credential authority: per-run OAuth snapshots fork the token family, rotation logs everyone out (grilled 2026-07-24)Agent Brief
Category: bug
Summary: Make the master credential store the only OAuth rotation site per provider: proactive host-side refresh, immediate fan-out of rotated credentials into live instance HOMEs, eager adopt-back of instance self-refreshes, and adopt-checks before every credential wipe.
Current behavior:
Each run gets a one-time snapshot of the master credential store into its private instance HOME at spawn, with no write-back and no later updates. When a long-lived instance's access token expires, its agent CLI refreshes its private copy, minting a new token family that invalidates the refresh token held by the master and every other snapshot. The host then reads logged-out, the spawn gate refuses new runs, and wipe-on-stop can destroy the only still-valid family. Both the claude-code and codex adapters share this snapshot design.
Desired behavior:
Invariant: one refresher per provider grant; instances are consumers, never refreshers.
claude -p— is the probe-confirmed non-interactive trigger; the CLI decides whether to rotate). Lab never parses token contents and never speaks any provider's OAuth protocol.Key interfaces:
provider.AgentProvidergrows three methods (settled in grilling):RefreshCredentials(ctx context.Context) error— drive the provider's CLI to refresh the master store, non-interactively, on the host;CredentialsSig(home string) string— opaque change-detector over the credential file(s) under a home,""meaning the master store (follow the existing SpoolSig precedent for shape and semantics);AdoptCredentials(instanceHome string) error— copy an instance HOME's credentials back into the master store.InjectCredentialsseam — it must remain the single injection call site.codex execcall, the analogue ofclaude -p); record the confirmed recipe in the codex compat notes.Acceptance criteria:
RefreshCredentialsand triggers fan-out exactly when the master signature changes — no fan-out on a no-op poke.""-means-master convention forCredentialsSig.RefreshCredentials, not the core loop).Out of scope: