Stop one missed credential write-back from killing a linked account - #172
Stop one missed credential write-back from killing a linked account#172imaustink wants to merge 3 commits into
Conversation
Anthropic ROTATES the refresh token on every refresh, so the instant a run's
CLI refreshes, the copy in the store is dead. That makes a missed write-back
not a degradation but a KILL: the stored refresh token has been spent, and only
a human re-link recovers. `credentialsWriteback.ts` already knew this -- it is
the first thing its doc comment says -- but it staked the whole link on a
single POST, once, after the turn:
- a pod killed, evicted, or hitting activeDeadlineSeconds mid-turn never
reached it at all;
- it had no retry, and release.yml deploys on every push to main, so a POST
arriving during a gateway rollout (ADR 0033 recorded eleven in fourteen
hours) simply lost the refresh.
One strike, and the user is asked to re-link something they linked hours ago.
Observed on PR #170: a run succeeded at 00:38, and the next run 12h later was
`authorized` with a credential Anthropic rejected ~20s in -- the orchestrator
then correctly invalidated it and logged `verdict:"link-required"`.
So persist continuously instead of once. `createCredentialsWritebackWatcher`
polls the credentials file during the turn (5s) and POSTs on change, which:
- shrinks the exposure window from "the whole turn" to one interval, so a pod
that dies mid-turn has already reported the refresh the CLI did seconds in;
- IS its own retry -- a tick that fails to store does not advance
`lastPersisted`, so the next tick re-sends the same blob;
- still flushes at the end, now with bounded retries, for a refresh landing
in the last seconds where there is no next tick.
`lastPersisted` advances only on a CONFIRMED store, which is what keeps a
failure from being mistaken for "already persisted", and re-reads the file
after storing so the next comparison is against exactly what was stored. Ticks
never stack (`inFlight`), and `stop()` waits out an in-flight tick so the final
flush cannot store a blob older than one already sent. When it does run out of
attempts it says so loudly, naming the consequence -- the link is stale from
that moment and nothing downstream can tell.
Also, the diagnostic whose absence made this expensive to find. The decisive
question is always "was the credential already dead when we injected it, or did
it die during the turn?", and answering it needs the agent pod's logs, which
are gone. So the orchestrator's `[authorization]` line -- which outlives the
run -- now carries `claudeLoginExpiresAt`, and the agent logs the seeded
credential's expiry at startup. Expiry ONLY: `logVerdict` handles the one
structure holding every resolved credential for a run, and the new helper is
tested to emit no token material.
Not fixed here, and the actual cure: every run still rotates a shared refresh
token, so concurrent runs for one subject can still invalidate each other. The
fix is for the gateway to become the single serialized owner of refresh, handing
runs a short-lived credential -- an ADR, not a patch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyKcQdvju68R3edo4GXD3T
…ving it
The previous commit made a run's own refresh survive the run. It did not make
anything REFRESH -- it only persisted refreshes that a pod happened to perform,
which left two holes it could not reach from inside the agent:
- two concurrent runs for one subject each refresh from the same stored token,
and whichever lands second invalidates the first;
- nothing refreshes between runs at all, and a refresh token is not valid
forever. The CLI itself names that failure: `refresh_token_expired` and
`refresh_token_expires_in` are both strings in the shipped binary. An
unexercised link is a link on a timer.
Both are fixed by moving refresh to the one component that is single, durable,
and able to serialize per subject. `ClaudeCredentialRefresher.ensureFresh` now
runs on the `login` read path -- `GET /claude-auth/api/token?mode=login` and
`/api/wait`, which is exactly what agent-orchestrator resolves a launch
credential through -- so a run is handed a freshly minted access token instead
of whatever was left in the store.
Serialization is the point, not an optimization: concurrent callers share one
in-flight promise per subject, so two simultaneous launches cost one refresh
instead of two that rotate each other out.
On the endpoint, and on what is actually verified. `POST
https://platform.claude.com/v1/oauth/token` is confirmed present in the shipped
CLI (v2.1.220), and the client id is lifted from the authorize URL this gateway
already hands users, so both are observed rather than assumed. The exact body
the service expects is NOT something this repo can prove from outside, and the
one thing that could make this worse is a refresh the service ACCEPTS whose
result we then lose. So the code is built around that:
- anything but a 2xx carrying a usable access_token/expires_in is inert --
`transient`, stored credential untouched, caller served exactly what it
would have been served before, retry later;
- a 200 we cannot parse is transient too, never "refreshed": claiming success
would overwrite a working credential with a broken one;
- `invalid_grant`/`refresh_token_expired` is `rejected` and reported, but the
record is deliberately NOT deleted -- the orchestrator's re-auth path owns
invalidation, and an endpoint hiccup that merely looked like a rejection
must not cost a working link;
- on success the new blob is persisted and read back BEFORE returning, and if
it will not persist we say so loudly and still hand back the live blob,
because at that moment it is the only living copy of the credential;
- a response that omits a new refresh token carries the old one forward
rather than writing `undefined` into the file, and every field the file
carried that this code has no business understanding is preserved, so runs
get a blob shaped like the one `claude auth login` produces.
`GATEWAY_CLAUDE_CREDENTIAL_REFRESH=false` turns it off entirely, and
`..._MARGIN_MS` tunes how close to expiry counts as due (30 min default). A
blob with no readable expiry is treated as due: an unknown expiry is much more
likely to be an expired credential than a healthy one, and a needless refresh
costs a round trip while a skipped one costs a re-link.
NOT covered, deliberately: a link that goes completely unused still ages out,
because refresh-on-read only fires when something reads. A background sweep
needs to enumerate stored subjects, and `SecretRecordStore` is documented as
having "no listing, no label-selector scan, no O(keyspace) anything" -- so that
is a design decision to take on purpose, not to smuggle in here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyKcQdvju68R3edo4GXD3T
Added: actual refresh, in the gateway (
|
… one Refresh-on-read cannot keep an UNUSED link alive: it fires only when something reads, and a refresh token is not valid forever -- the CLI ships `refresh_token_expired` and `refresh_token_expires_in` as strings, so an idle link is on a timer whether or not we act. A user who links today and triggers nothing for long enough is still asked to link again, for no reason they can see. `ClaudeCredentialSweeper` runs in the gateway (the only long-lived single-instance component holding the store) and renews every `login` credential inside a wide margin, hourly by default, with a pass at startup so a deployment neither waits an interval to begin nor waits one to discover the sweep is broken. It refreshes through `ClaudeCredentialRefresher.ensureFresh` rather than directly, so a sweep landing next to a launch collapses onto that subject's single in-flight refresh instead of the two of them rotating each other's token out. Sequential, because there is no hurry and a scheduled burst of token requests is a worse neighbour than a slow loop. The margin is deliberately wider than the on-read one (4h vs 30m): the sweep's job is that a link is never FOUND dead, while the read path's job is only that the blob it is about to hand out works. This needs the one thing the store was built not to do. `SecretRecordStore`'s naming section says "no listing, no label-selector scan, no O(keyspace) anything", and `listRecords` is an explicit exception to it, documented as maintenance-only. The rule is about the READ path, where an exact `get` by computed name is both possible and required; a sweep has no key to compute a name from, so enumerating IS the task -- and `commonLabels` already existed "so an operator -- and a cleanup sweep -- can find them all". The scan is label-selector scoped to one record type, and a record whose plaintext key was never stored is skipped rather than guessed at, since the object name is a one-way hash and acting on an unattributable credential is worse than leaving it alone. Two failure modes are handled as loud rather than quiet, because both would otherwise look exactly like a healthy sweep right up until someone's link expired: a store that cannot enumerate at all makes the sweeper stand down with a message instead of silently never running, and `listSubjects` deliberately does NOT swallow its error the way `get`/`set`/`delete` do -- a list that failed is not an empty store, and reading it as one is how a sweep does nothing forever. No new RBAC: the gateway's Role already grants `list` on Secrets (the API server requires it for the `watch` the pending-link resume uses). Its comment now says the sweep uses it directly too. Tunable via GATEWAY_CLAUDE_CREDENTIAL_SWEEP_INTERVAL_MS / _SWEEP_MARGIN_MS, and disabled wholesale by GATEWAY_CLAUDE_CREDENTIAL_REFRESH=false alongside the read-path refresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyKcQdvju68R3edo4GXD3T
Added: background auto-refresh (
|
The recurring "Your linked Claude Remote Control account's credential expired" prompt. This is not a keying bug (ADR 0029/0031 closed those) — it's a durability bug with a one-strike failure mode.
What's actually happening
Anthropic rotates the refresh token on every refresh, so the instant a run's CLI refreshes, the copy in the store is dead. That makes a missed write-back not a degradation but a kill: the stored refresh token has been spent, and only a human re-link recovers.
credentialsWriteback.tsalready knew this — it's the first thing its doc comment says — but it staked the whole link on a single POST, once, after the turn:activeDeadlineSecondsmid-turn never reached it at all;release.ymldeploys on every push tomain, so a POST arriving during a gateway rollout (ADR 0033 recorded eleven in fourteen hours) simply lost the refresh.One strike, and the user re-links something they linked hours ago.
Evidence from prod (observed on #170)
verdict:"authorized",CLAUDE_LOGIN_CREDENTIALS_JSONinjected — the credential existedverdict:"link-required"The write-back route itself is correct (stores
kind:"login", subject from the grant token, verified by read-back). The credential was genuinely stale on arrival.The change
createCredentialsWritebackWatcherpolls the credentials file during the turn (5s) and POSTs on change:lastPersisted, so the next tick re-sends the same blob;lastPersistedadvances only on a confirmed store — that's what keeps a failure from being mistaken for "already persisted" — and the file is re-read after storing so the next comparison is against exactly what was stored. Ticks never stack (inFlight), andstop()waits out an in-flight tick so the final flush can't store a blob older than one already sent. When it runs out of attempts it says so loudly and names the consequence, since nothing downstream can tell.The diagnostic whose absence made this expensive
The decisive question is always "was the credential already dead when we injected it, or did it die during the turn?" — and answering it needs the agent pod's logs, which are gone. So:
[authorization]line (which outlives the run) now carriesclaudeLoginExpiresAt;Expiry only.
logVerdicthandles the one structure holding every resolved credential for a run, and there's a test asserting the new field emits no token material.Not fixed here — the actual cure
Every run still rotates a shared refresh token, so concurrent runs for one subject can still invalidate each other. The real fix is for the gateway to become the single serialized owner of refresh, handing runs a short-lived credential. That's an ADR, not a patch, and I'd rather land the containment first.
Tests
50 pass in
claude-code-swe-agent(7 new for the watcher + expiry parsing), 42 in the orchestrator's authorization service (1 new). Repo-wide: 6 pre-existing failing files, identical to cleanmain.Each watcher test is a way this used to break: a refresh mid-turn persisted without waiting for the turn to end; one store per refresh rather than one per tick; a failing gateway retried across ticks and stored on recovery; the final flush retried through a bad POST; a loud give-up; and inert when no grant was injected.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NyKcQdvju68R3edo4GXD3T