SIGN IN SIGN UP

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