SDK OAuth refresh silently skipped when expiresAt is outside the 5-minute window (Gate 2 ignores force=true)
Summary
In the vendored SDK (@anthropic-ai/claude-code cli.js, reproduced on v2.1.111), the OAuth token-refresh function UR1(q, K) has two gates:
- Gate 1 (
XQ(z.expiresAt)against the in-memory token) — bypassed whenK=true(theforceflag). - Gate 2 (
XQ(Y.expiresAt)against the token re-read from disk) — never bypassed, regardless ofK.
async function UR1(q, K) {
let z = o7();
if (!K) {
if (!z?.refreshToken || !XQ(z.expiresAt)) return false // Gate 1 (respects force)
}
if (!z?.refreshToken) return false;
let Y = await To6(); // re-read from disk
if (!Y?.refreshToken || !XQ(Y.expiresAt)) return false // Gate 2 (ignores force)
// ...refresh exchange...
}
function XQ(q) { return Date.now() + 300000 >= q } // 5-minute window
The 401 handler calls UR1(0, true). Gate 1 passes. Gate 2 reads disk and applies XQ again — if the token on disk has expiresAt more than ~5 minutes in the future, Gate 2 returns false and the refresh is silently skipped. The caller sees a failed refresh and the agent dies with an unrecoverable auth error, even though the refreshToken on disk is perfectly valid.
Impact
I've been spawning subprocess-based sub-agents that inherit credentials from a parent session. Parent-session tokens typically have expiresAt ≈ now + 45min. When a sub-agent hits 401 mid-flight (token rotation by another session, or simple staleness), the OAuth retry path fires, but Gate 2 sees expiresAt = now + 45min, XQ returns false, and the refresh is skipped. The sub-agent crashes.
In a 24-hour window I observed three separate kill events, each taking down 5–6 sub-agents in quick succession, with timing that correlates with rolling token expiry across the fleet.
Reproduction
- Valid
.credentials.jsonwithexpiresAt = Date.now() + (45 * 60 * 1000)(45 minutes in the future). - Access token is actually stale (e.g. rotated by a parallel session) so the next API call 401s.
- SDK receives 401, enters
$B/_Y(0, true)/UR1(0, true). - Gate 1 passes (force bypass).
- Gate 2 re-reads the disk,
XQ(now + 45min)returnsfalse, refresh is skipped. - Agent throws a permanent auth failure. No retry, no observable error mentioning the token at all.
A naive test harness that writes expiresAt = now + 2min will NOT reproduce this — Gate 2 passes and the refresh succeeds. That is why the bug has been shipping silently.
Expected behavior
When K=true (force), UR1 should perform the refresh exchange regardless of XQ(expiresAt) on the disk copy. The whole point of the force flag is to recover from a 401 where the server has told us the token is dead — expiresAt on disk is no longer authoritative evidence of validity.
Suggested minimal patch:
async function UR1(q, K) {
let z = o7();
if (!K) {
if (!z?.refreshToken || !XQ(z.expiresAt)) return false
}
if (!z?.refreshToken) return false;
let Y = await To6();
if (!Y?.refreshToken) return false; // still need refreshToken to exist
if (!K && !XQ(Y.expiresAt)) return false // respect force here too
// ...refresh exchange...
}
Observability note
I have a full static-analysis + runtime-trace write-up including FS-watch evidence, SDK debug probe output, and five reproduction runs here: https://github.com/arthureffting/claude-config/pull/87 — happy to share anything else that helps triage.
Environment
@anthropic-ai/claude-codecli.js v2.1.111- Windows 11, git-bash
- Node 20
Thanks for looking at this.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗