fix: reload .credentials.json on cache miss to detect external updates (#220)
## Summary
Closes #219.
On non-macOS platforms, `refreshIfNeeded` consults only
`target.credentials`, which is the in-memory snapshot from
`readAllClaudeAccounts()` at plugin init. External writes to
`~/.claude/.credentials.json` (e.g. by
[switch_claude_account](https://github.com/countzero/windows_switch_claude_account)
on Windows) are never observed, and after ~1h the OAuth-refresh path
submits the stale in-memory `refreshToken`, then
`writeBackCredentials("file", ...)` re-reads the (externally-updated)
file and splices the stale account''s tokens into the new account''s
JSON, corrupting it.
This patch re-reads the file in `refreshIfNeeded` for `target.source ===
"file"`, before the expiry check. The in-place update of
`target.credentials` propagates to `allAccounts`, so subsequent
`getCachedCredentials` calls observe the new tokens. When the on-disk
creds are still valid, the `expiresAt > now + 60_000` early-return fires
and the OAuth-refresh + writeback path is structurally unreachable,
eliminating the corruption vector.
## Implementation
Single-file change in `src/credentials.ts`. Reuses the already-imported
`refreshAccount(source)` (which for `source === "file"` is just
`readCredentialsFile()`); no new exports, no new imports.
```typescript
export function refreshIfNeeded(account?: ClaudeAccount): ClaudeCredentials | null {
const target = account ?? getActiveAccount()
if (!target) return null
// Pick up external updates to .credentials.json (e.g. switch_claude_account
// on Windows). Bounded by getCachedCredentials's 30s TTL: fires at most
// ~2x/min under load. 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.
if (target.source === "file") {
const onDisk = refreshAccount(target.source)
if (onDisk) target.credentials = onDisk
}
const creds = target.credentials
if (creds.expiresAt > Date.now() + 60_000) return creds
// ... unchanged ...
}
```
## Tests
Two new tests in `src/credentials.test.ts` (existing
`loadCredentialsWithCountingKeychain` harness extended with
`__setCredentials` / `__getWriteCount` helpers):
- **Reload-on-external-update**: account holds stale-but-valid creds;
mock keychain mutated to new accessToken; `refreshIfNeeded` returns new
creds and updates `account.credentials` in place.
- **No-writeback-when-on-disk-fresh**: account holds creds expiring
within 60s (would normally trigger OAuth refresh); mock keychain returns
fresh creds; `refreshIfNeeded` returns the fresh creds and
`writeBackCredentials` was NOT called. Encodes the corruption-prevention
invariant.
## Performance
Bounded by the upstream 30s `CREDENTIAL_CACHE_TTL_MS` in
`getCachedCredentials`, so this fires at most ~2x/min under load. Disk
read of a small JSON file is negligible.
## Scope
- Only `target.source === "file"` — keychain sources are unaffected.
- The 30s `CREDENTIAL_CACHE_TTL_MS` is unchanged; max stale window after
an external write is still 30s.
- Not addressed: `getCredentialsForSync` (used by the 5-min `auth.json`
background sync) still returns the in-memory snapshot. With this patch
the snapshot is updated whenever there''s traffic; an idle process still
writes stale creds to `auth.json` until the next request triggers a
cache miss. Worth a follow-up if it surfaces.
## Verification
- `pnpm test`: all credential-caching tests pass, including the 2 new
ones. The 3 pre-existing `keychain.test.ts > writeBackCredentials (file
source)` failures on Windows are unrelated (the tests set
`process.env.HOME`, which `os.homedir()` ignores on Windows in favour of
`USERPROFILE`); CI on Ubuntu is green.
- `pnpm run lint`: oxlint clean (0 warnings, 0 errors). F
Finn Kumkar committed
6ff5dc76536cd0fbc5dd1b1a456cba968e642787
Parent: 2ce824f
Committed by GitHub <noreply@github.com>
on 5/15/2026, 4:31:22 AM