Skip to content

fix: handle external rotation of the Claude Code credential - #260

Merged
griffinmartin merged 33 commits into
mainfrom
griffinmartin/cswap
Jul 30, 2026
Merged

fix: handle external rotation of the Claude Code credential#260
griffinmartin merged 33 commits into
mainfrom
griffinmartin/cswap

Conversation

@griffinmartin

Copy link
Copy Markdown
Owner

Summary

The plugin assumed it was the only writer to the active Claude Code credential. src/credentials.ts stated it outright:

macOS keychain sources stay on the in-memory path; their state is mutated only by our own writeBackCredentials, so no external-update vector exists for them.

That is false. claude-swap, the claude CLI in another terminal, and second OpenCode instances all rotate it, as does a server-side revocation.

The user-visible bug: an external account switch produces no 401, because the previous access token is still valid. Nothing triggered a re-read, so an OpenCode session stayed pinned to whatever account was active at startup for the ~10h life of that token. Running cswap auto had no effect on OpenCode at all.

Four changes, none claude-swap-specific — no new configuration, no environment variable, no subprocess, no dependency on cswap being installed:

Change Effect
refreshIfNeeded re-reads every source, not just file An external switch reaches a live session within one cache TTL instead of never
Compare-and-swap on writeBackCredentials A refresh whose OAuth round trip straddled a switch can no longer write account A's tokens into account B's slot
401 handler → bounded two-attempt recovery loop Wires up forceRefreshActiveAccount, which was fully written, documented and tested with zero call sites
429 triggers one source re-read A rate limit already resolved by an external switch is not surfaced

The re-read validates before adopting: a usable stored blob is always taken, an unusable one only when what is already held is also unusable. Without that, a failed write-back resurrects the pre-refresh token and loops claude CLI spawns every 30s.

Why cooperating with cswap is safe, verified in its source rather than assumed: switcher.py:4694 copies live credential bytes into the departing account's slot on switch, so a token this plugin rotates is captured rather than orphaned.

Also fixed

  • The force-refresh path wrote refreshed credentials to the default config directory instead of the account's own, so a file-source account with a custom CLAUDE_CONFIG_DIR could be written to the wrong file. Latent, since that path had no callers until now.

Behavior change

  • A 401 that survives recovery is now returned without SSE stream transformation, matching what a non-retried 401 already did.

Not covered by the generated changelog

CHANGELOG.md is release-please generated from commit subjects. Both items above live inside commits whose subjects do not mention them, so neither will appear in the release notes automatically.

Test plan

Suite: 249 → 274 passing, lint clean, tsc clean.

Review found three places where a test passed while the code was broken. Each is now pinned and mutation-verified:

  • 429 retry sending the exhausted token instead of the rotated one — was green, now fails
  • Keychain-branch CAS guard deleted outright — was green on all 268, now fails
  • MAX_AUTH_RECOVERY_ATTEMPTS raised 2 → 5 — was green, now fails
  • Borrowed-flag invariant on the reload adoption path — proven to make a recoverable 401 reach the user
  • credentialBlobMatches unit + both write-back branches end-to-end

Every automated test stubs the Keychain, so the following still needs doing against real state before release:

  • cswap list — confirm two accounts, note the active one
  • Start OpenCode, send a prompt so credentials are cached
  • cswap switch in another terminal
  • Wait 30s, send another prompt in the same session — confirm it uses the new account
  • With CLAUDE_AUTH_DEBUG=1, check for the new account's token and no writeback_skipped_stale storm
  • cswap list — confirm neither slot is quarantined for a dead refresh token

Notes for review

Design and implementation plan are committed under specs/, including a Follow-ups section recording nine deliberately deferred findings with rationale — among them that performRefresh discards writeBackCredentials's return value, that the post-OAuth file-source exclusion is now an unjustified historical artifact, and that two functions remain dead with docstrings that no longer describe the code.

…ed up

refreshIfNeeded re-read only file sources, on the stated assumption that a
keychain entry is mutated solely by our own writeBackCredentials. That is
false: cswap, the claude CLI in another terminal, and a second OpenCode
instance all rotate it. An external switch produces no 401, so nothing
triggered a re-read and the session stayed pinned to the account that was
active at startup for the ~10h life of its token.

Re-reading every source also stops the proactive refresh timer writing a
stale account's rotated tokens over whichever account is now active.

refreshAccount throws for keychain sources when the keychain is locked,
access is denied, or `security` times out — a new throw path on the request
path, hence the catch that degrades to the in-memory credentials.

Test repairs, all one root cause: the up-front re-read makes the stub's
source blob visible to accounts whose in-memory credentials differ, so
several tests needed their source pinned or their read counts corrected.
Three were anticipated. Three were not, and are worth noting:

- "reloadCredentialsFromSource bypasses cache": primed via
  __setCredentialsForSource so the rotation it tests still lands.
- "does not spawn the claude CLI while credentials are still usable":
  source pinned to the account's own value.
- "falls back to the claude CLI once credentials reach the expiry window":
  its __setReadHook rotated the store on read #2, which is now the pre-CLI
  re-read rather than the post-CLI one. The result assertion still passed
  while the CLI path silently stopped being exercised; the threshold moves
  to read #3 to restore the original coverage.
The previous wording gave a rationale for the second source read — a sibling
process can write during the OAuth round trip — that applies just as well to
file sources, then excluded them anyway "as they did when the up-front
re-read was file-only". That describes the old behavior rather than
justifying the new one, and reads as though the asymmetry were principled.

It isn't. Removing the guard is a behavior change on the file path and is
tracked as a follow-up; say so plainly so the next reader does not mistake
it for an invariant worth preserving.
The re-read adopted any non-null blob over valid in-memory credentials.
performRefresh ignores writeBackCredentials's return value, and that write
can fail while the read before it succeeded — updateCredentialBlob returns
null on a malformed blob, and an ACL can permit read but not
add-generic-password. Memory then holds freshly refreshed credentials while
the store holds the pre-refresh blob, which has under 60s left because that
window is the only reason we refreshed at all.

Adopting it re-entered performRefresh with a refresh token our own
successful refresh had just rotated dead: OAuth fails, the post-OAuth
re-read finds the blob it already adopted, and it falls through to two 60s
claude spawns — on every cache miss, every 30s, indefinitely, on the
authentication path. Keychain sources were immune to this before the
up-front re-read was extended to them.

The rule is: adopt a usable stored blob always, an unusable one only when
what we hold is unusable too. A plain freshness guard would also decline an
expired blob written by a legitimate external switch, reintroducing the bug
this re-read exists to fix. The residual cost is recorded at the call site.

Also clear the borrowed flag on adopt. The re-read pulls from the account's
own source, so anything it adopts is the account's own credentials;
refreshBorrowedAccount already pairs every adoption with the same delete.
Left set, forceRefreshActiveAccount would refuse to exchange tokens that
are legitimately the account's.
The guard's rationale claimed the orphaned pre-refresh blob "has under 60s
left, since that window is the only reason we refreshed". That holds on the
reactive path but not on the proactive one: the sync timer passes
PROACTIVE_REFRESH_THRESHOLD_MS (1h), so a failed write-back there orphans a
blob with up to an hour remaining — which the guard's first clause reads as
usable and adopts.

The guard is unchanged and still correct; the comment was under-describing
the remaining exposure, and a maintainer would have read the failure as
fully closed. The proactive case is categorically milder: the adopted token
is genuinely valid so requests keep succeeding, and performRefresh's
CLI_FALLBACK_THRESHOLD_MS gate declines to spawn claude while credentials
remain usable, so the cost is wasted background refreshes until the blob
drops under 60s and the CLI fallback recovers.

Also record that no guard on the re-read can close it: the re-read cannot
distinguish "store is stale because our write failed" from "store changed
because cswap switched", since both present as store-disagrees-with-memory
and store-is-usable. That distinction exists only in the return value
performRefresh discards, which is tracked separately.
Split writeback_skipped_stale into stale and unparseable: a mismatch is
an expected account switch, an unreadable blob is corruption, and only
the second needs a human. Both now carry hashed token fingerprints so
the decision can be correlated without logging token material.

Cover the two call sites that had no test: the borrowed-recovery path,
where guarding with the lender's token would have failed every CAS
silently, and the force-refresh path, where reading the token after the
await would compare the refreshed token against the store.
@griffinmartin
griffinmartin marked this pull request as ready for review July 29, 2026 23:10
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a long-standing assumption that this plugin was the sole writer to the active Claude Code credential, enabling external tools (the claude CLI, cswap, other OpenCode instances) to rotate credentials without the session noticing. Four coordinated changes make this work: refreshIfNeeded now re-reads every source (not just file) on each cache miss; writeBackCredentials gains a compare-and-swap guard using the pre-refresh access token; the 401 handler is replaced with a bounded two-attempt recovery loop that finally wires up the previously dead forceRefreshActiveAccount; and a 429 triggers a single source re-read so a rate limit resolved by an external switch is not surfaced.

  • refreshIfNeeded (credentials.ts) now re-reads keychain sources in addition to file sources on every cache miss, wrapped in a try/catch to degrade gracefully when the keychain is locked or access is denied; adoption logic explicitly guards against resurrecting an orphaned pre-refresh blob.
  • writeBackCredentials (keychain.ts) now accepts an expectedPriorAccessToken and refuses the write when the stored blob has already been replaced, preventing account A's refreshed tokens from landing in account B's slot during a concurrent switch.
  • The 401 fetch path (index.ts) replaces a single retry with a MAX_AUTH_RECOVERY_ATTEMPTS = 2 loop that first checks whether an external rotation has already resolved the problem before forcing a full OAuth round-trip; a surviving 401 is returned untransformed rather than wrapped in the SSE transform.

Confidence Score: 5/5

Safe to merge. The four changes are well-scoped, all new code paths are protected by try/catch with graceful degradation, and the test suite grew by 25 cases with mutation verification on previously green-but-broken tests.

The core logic — source re-read on cache miss, CAS write-back guard, bounded 401 recovery loop, single 429 re-read — is correct and carefully documented. No existing callers were broken. The two observations (429 → retry → 401 escapes recovery; borrowed-flag assumption on file accounts) are edge cases in already-exotic paths that the author has explicitly flagged as follow-up territory in the specs. None introduce wrong data or broken state for the common cases.

Files Needing Attention: The if (target.source !== 'file') guard in performRefresh (credentials.ts, line 463) is the most likely source of future confusion — it is documented as a follow-up but the rationale for leaving it is only in the code comment and the specs directory, not in a ticket or TODO with a clear owner.

Important Files Changed

Filename Overview
src/credentials.ts Core changes: refreshIfNeeded now re-reads every source (keychain and file) on cache miss; performRefresh has a documented-but-unresolved file-source exclusion; forceRefreshActiveAccount gains CAS write-back and cache update; reloadCredentialsFromSource clears the borrowed flag correctly.
src/keychain.ts New credentialBlobMatches and CAS logic in writeBackCredentials are clean and correctly implement the compare-and-swap guard for both file and keychain paths.
src/index.ts 401 recovery loop wired correctly; 429 re-read ordered after the loop and correctly compares against tokenInUse; a 401 from the 429 retry is returned untransformed without running through recovery, which is intentional but limits handling of a narrow edge case.
src/credentials.test.ts 25 new tests covering the borrowed-flag invariant, CAS write-back, configDir forwarding, source re-read adoption logic, and mutation verification for previously green-but-broken cases.
src/index.test.ts New tests pin the 401 recovery loop cap, the reload-throws path, 429 re-read ordering, and the rotated-store-on-every-read termination guarantee.
src/keychain.test.ts credentialBlobMatches unit tests and two writeBackCredentials end-to-end branches (match/mismatch) added; mock security layer extended to record and replay add-generic-password writes.

Reviews (2): Last reviewed commit: "docs: close the configDir follow-up and ..." | Re-trigger Greptile

…rite-back

Every refreshAccount call site in this file forwards the account's
configDir except the two re-read paths. Unreachable today — every file
account is assigned CLAUDE_CONFIG_DIR ?? ~/.claude, exactly what
readCredentialsFile recomputes by default — but the compare-and-swap
guard makes the read and the write resolving to the same file
load-bearing, so make that hold by construction rather than by
coincidence.

performRefresh discarded writeBackCredentials's return value, so a
rejected write-back left no trace anywhere in the debug log while
forceRefreshActiveAccount logged the same condition. Log it. Acting on
it is a control-flow change that interacts with the adoption logic this
branch introduces, and stays deferred.

Also drops reloadActiveAccount's claim to be used on 401; it has no
call sites.
Records reads and lets the write-back result be forced in the keychain
stub, and captures log events in the logger stub, so all three arms are
observable. Mutation-verified: reverting either configDir forward or
inverting the write-back check fails exactly the matching test.
The configDir asymmetry is fixed and pinned. The discarded write-back
return value is now half-closed: the failure is logged, while not
re-adopting an orphaned-but-usable blob on the proactive path stays
deferred.
@griffinmartin

Copy link
Copy Markdown
Owner Author

Reviewed all three. One diagnosis is wrong, one is understated, and two are staying deferred. Pushed b080afc, c5332c3, 4311539.

1. reloadActiveAccount omits account.configDir — premise incorrect, underlying issue fixed anyway

The stated failure mode cannot occur:

  • refreshAccount only consults configDir in its file branch (keychain.ts:582-584); for keychain sources it is ignored outright.
  • There are exactly two source: "file" constructions, keychain.ts:326 and keychain.ts:356, and both set configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude") — character-for-character readCredentialsFile's own default parameter (keychain.ts:212).
  • The only .configDir = mutation (keychain.ts:395) is in the keychain branch, where source is a service name, never "file".

So "a file-source account where the active config directory differs from process.env.CLAUDE_CONFIG_DIR" does not exist, and there is no asymmetry between the two functions — reloadCredentialsFromSource and reloadActiveAccount both call refreshAccount(account.source) and are unreachable for the identical reason. The claim that one "has no such guarantee" while the other is "fine in practice" is not supported; the guarantee comes from the shared construction sites, not from either call site.

Fixed regardless, for a different reason than the one given: every other refreshAccount call site in credentials.ts forwards configDir, and the CAS guard this PR adds makes the read and the write resolving to the same file load-bearing. Both paths now forward it by construction rather than by coincidence. Pinned by a test per path, mutation-verified.

The docstring's "Used on 401" claim was false and now records that the function has no call sites. Wire-up-or-delete stays a follow-up — that is a public-API removal (dist/credentials.d.ts:44), not review cleanup.

2. File-source exclusion — staying deferred

This one asks to "confirm the follow-up lands before the next release" rather than to change anything, so nothing to resolve. Removing if (target.source !== "file") changes behavior on the file path, which this PR deliberately kept out of scope and which no current test covers. It needs its own tests and its own entry in the manual-verification checklist. Tracked at specs/2026-07-29-external-credential-rotation-plan.md:1189 with that rationale.

3. Discarded write-back return value — log gap closed, control flow deferred; also, this is understated

"There is no silent data loss" is not right. The repo's own note (plan.md:1195) describes the consequence: on the proactive path the orphaned blob can still have ~1h left, so the validated re-read sees it as usable and adopts it, clobbering the freshly refreshed credentials and burning background OAuth attempts until it drops under 60s. That is a real misbehavior, not a cosmetic omission.

Split it:

  • Log gap — fixed. performRefresh now checks the return and logs refresh_writeback_failed, mirroring force_refresh_writeback_failed. Pure observability, no control-flow change, and it matters for this PR's manual checklist, which greps the debug log. Pinned by a test that forces the CAS to reject.
  • Acting on the false — deferred. The correct fix is to not re-adopt the orphaned blob, which interacts directly with the adoption logic this PR introduces. Doing it here would put untested behavior on the path the PR exists to make correct.

Suite 274 → 277, lint and tsc clean. All three new tests confirmed to fail when the corresponding change is reverted.

@griffinmartin
griffinmartin merged commit 5a44883 into main Jul 30, 2026
5 checks passed
@griffinmartin
griffinmartin deleted the griffinmartin/cswap branch July 30, 2026 03:21
griffinmartin pushed a commit that referenced this pull request Jul 30, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.1.5](v2.1.4...v2.1.5)
(2026-07-30)


### Bug Fixes

* handle external rotation of the Claude Code credential
([#260](#260))
([5a44883](5a44883))
* refresh OAuth tokens with native fetch instead of a subprocess
([#258](#258))
([231165b](231165b))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant