RFC: Prevent parallel auth kickout via token refresh serialization
Summary
Parallel claude -p processes cause VS Code/Cursor extension to lose authentication. This has been reported across 7+ issues (#24317, #37512, #37203, #37324, #37468, #25609, #22600) and affects every user running concurrent sessions on macOS.
The v2.1.81 fix addressed session-level races but not subprocess/subagent scenarios. RFC #29077 proposed 6 comprehensive fixes but was closed without merge.
Root Cause (from cli.js v2.1.81 source analysis)
- Single Keychain entry — all processes share
Claude Code-credentials - Rotating refresh tokens — server invalidates old token on refresh
proper-lockfile— 5 retries × ~1.5s = 7.5s budget, insufficient for 10+ processes- 5-second cache TTL — in-memory Keychain cache causes stale reads between processes
- No refresh dedup across processes — in-process Promise dedup exists, but cross-process = only filesystem lock
Proposed Fixes (prioritized)
Fix 1: Increase lock budget (minimal change)
// Current: 5 retries, 1-2s delay = ~7.5s max wait
// Proposed: 20 retries, 1-3s delay = ~40s max wait
Tu7.lock(configDir, { retries: 20, delay: 1000 + Math.random() * 2000 })
Impact: Reduces lock contention failures. Won't fix the root cause but buys time.
Fix 2: Read-after-lock optimization (medium change)
After acquiring lock, the CLI already re-reads Keychain. If another process refreshed during the wait, it emits tengu_oauth_token_refresh_race_resolved and returns. This works correctly. The problem is processes that fail to acquire the lock — they should also re-read Keychain before giving up, because the lock holder likely already refreshed.
// On ELOCKED after max retries:
clearAllCaches();
const fresh = await readFromKeychain();
if (fresh && !isExpired(fresh.expiresAt)) {
emit('tengu_oauth_token_refresh_lock_timeout_recovered');
return fresh; // Use the token another process refreshed
}
// Only fail if token is truly expired AND lock unavailable
Fix 3: CLAUDE_CODE_OAUTH_TOKEN should be read-only (bug fix)
Currently, setting this env var causes the CLI to delete Keychain credentials on exit (#37512). The env var should make the process entirely read-only with respect to credential storage:
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
// Skip ALL credential writes, deletes, and cleanup
return;
}
Fix 4: Non-rotating refresh tokens (server-side)
If the OAuth server issued non-rotating refresh tokens (old refresh token remains valid after use), the race condition becomes harmless — multiple processes can refresh without invalidating each other. Google OAuth supports this via configuration.
Fix 5: Token broker pattern (architectural)
A single process (or launchd agent) owns Keychain access and serves tokens via IPC. All CLI instances request tokens from the broker. This is how gcloud solved the same problem.
Fix 6: Reduce Keychain cache TTL
The 5-second in-memory cache (UI7=5000) means a freshly-refreshed token takes up to 5s to be visible to other processes. Reducing to 500ms would narrow the stale-read window significantly.
Community Workaround
We built claude-batch — a tmux-based wrapper that prevents refresh from occurring during batch runs. Strategy: pre-batch force refresh → 2h token gate → batch completes within token lifetime → no refresh triggered → no race.
Validated by 7-model consilium (Gemini, Grok-4, DeepSeek, Mistral, Codex, Qwen, Claude Opus), 3 rounds code review, 1 red team. 6/6 APPROVE.
References
- #24317 — Primary tracking issue
- #37512 — CLAUDE_CODE_OAUTH_TOKEN deletion bug
- #29077 — Previous RFC (closed without merge)
- Source:
_P1()(refresh),NA9()(401 recovery),FI7(fallback combiner),UI7=5000(cache TTL)
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗