diff --git a/src/servers/sidecar/claude/proxy.ts b/src/servers/sidecar/claude/proxy.ts index ef22355a..b4618fe2 100644 --- a/src/servers/sidecar/claude/proxy.ts +++ b/src/servers/sidecar/claude/proxy.ts @@ -1,5 +1,5 @@ import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { homedir, userInfo } from 'node:os'; import { getState, updateState } from './state'; const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051'); @@ -11,6 +11,38 @@ const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; // Buffer: refresh 5 minutes before expiry const EXPIRY_BUFFER_MS = 5 * 60 * 1000; +/* + macOS keeps Claude Code's OAuth credentials in the login Keychain, NOT in ~/.claude/.credentials.json. + + This proxy used to read the file only, which on a Mac is a file Claude Code never writes. The + workaround was to copy the Keychain blob into it by hand — and that is a snapshot, so it dies the + first time the real client rotates the token. Worse than dying: a refresh ROTATES the refresh token + and revokes the previous one, so two stores holding what looks like the same credential are actually + racing, and whichever refreshes second gets `401 OAuth access token has been revoked`. That is the + failure this file now exists to prevent, and it took out every mobile chat turn for six hours on + 2026-08-08 while the terminal CLI beside it worked fine. + + So on darwin the Keychain is the authority and the file is a mirror. Elsewhere — every Linux server — + the file is the authority and nothing here changes. +*/ +const IS_MACOS = process.platform === 'darwin'; +const KEYCHAIN_SERVICE = 'Claude Code-credentials'; + +/** Which store a credential came from, so a refresh is written back to the one that owns it. */ +type CredentialSource = 'keychain' | 'file'; +type LoadedCredentials = { creds: CredentialsFile; source: CredentialSource }; + +/* + How far ahead the watchdog works. + + It CHECKS every 30 minutes and refreshes only when the token has under an hour left, rather than + refreshing on a blind schedule. The distinction matters: every refresh rotates the token, so a + pointless refresh is not free — it is another chance for the two stores to disagree. This keeps a + valid token permanently ahead of the next request without spending one to do it. +*/ +const WATCHDOG_INTERVAL_MS = 30 * 60 * 1000; +const WATCHDOG_REFRESH_WITHIN_MS = 60 * 60 * 1000; + type OAuthCredentials = { accessToken?: string; refreshToken?: string; @@ -39,6 +71,92 @@ async function writeCredentials(creds: CredentialsFile): Promise { } } +/** Run `security`, returning stdout on success and null on any failure. Never throws. */ +async function runSecurity(args: string[]): Promise { + try { + const proc = Bun.spawn(['security', ...args], { stdout: 'pipe', stderr: 'pipe' }); + const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + return code === 0 ? out : null; + } catch { + // No `security` binary, or spawning is not permitted. Both mean "no Keychain here". + return null; + } +} + +/* + The Keychain account the item is filed under — the macOS short username in practice. + + Read from the existing item rather than assumed, because `add-generic-password -U` matches on the + service AND account pair: guess the account wrong and it silently creates a SECOND item instead of + updating the one Claude Code reads, which would look like a write that worked and changed nothing. +*/ +let keychainAccount: string | null = null; +async function getKeychainAccount(): Promise { + if (keychainAccount) return keychainAccount; + const out = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE]); + keychainAccount = out?.match(/"acct"="([^"]*)"/)?.[1] || userInfo().username; + return keychainAccount; +} + +async function readKeychainCredentials(): Promise { + if (!IS_MACOS) return null; + const raw = await runSecurity(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']); + if (!raw?.trim()) return null; + try { + const parsed = JSON.parse(raw.trim()) as CredentialsFile; + // An item with no access token is not a credential. Fall through to the file rather than + // adopting it — an empty Keychain entry must not shadow a working one on disk. + return parsed.claudeAiOauth?.accessToken ? parsed : null; + } catch { + return null; + } +} + +async function writeKeychainCredentials(creds: CredentialsFile): Promise { + if (!IS_MACOS) return false; + const account = await getKeychainAccount(); + // The blob goes through argv, which is the only interface `security` offers for a non-interactive + // write — it has no stdin form. Acceptable here: this is a single-user laptop whose Keychain the + // caller already holds, and the alternative is leaving Claude Code's own store stale. + const out = await runSecurity([ + 'add-generic-password', + '-U', + '-a', + account, + '-s', + KEYCHAIN_SERVICE, + '-w', + JSON.stringify(creds), + ]); + return out !== null; +} + +/** + * The credentials to work from, and where they came from. + * + * Keychain first on macOS: it is what the real Claude Code reads and writes, so it is the copy that + * is current even when nothing here has run for hours. + */ +async function loadCredentials(): Promise { + const fromKeychain = await readKeychainCredentials(); + if (fromKeychain) return { creds: fromKeychain, source: 'keychain' }; + const fromFile = await readCredentials(); + return fromFile ? { creds: fromFile, source: 'file' } : null; +} + +/** + * Persist a refreshed credential to the store that owns it, and mirror it to the file. + * + * The mirror is deliberate. Both copies then hold the SAME token rather than two rotations of it, + * which is the difference between a redundant copy and the race this file was written to end. + */ +async function saveCredentials(creds: CredentialsFile, source: CredentialSource): Promise { + if (source === 'keychain' && !(await writeKeychainCredentials(creds))) { + console.error('[claude:proxy] could not write the Keychain — the file mirror is now the only copy'); + } + await writeCredentials(creds); +} + async function refreshOAuthToken(refreshToken: string): Promise { try { console.log('[claude:proxy] refreshing OAuth token...'); @@ -79,10 +197,11 @@ async function refreshOAuthToken(refreshToken: string): Promise { - const creds = await readCredentials(); - if (!creds?.claudeAiOauth) return null; + const loaded = await loadCredentials(); + const oauth = loaded?.creds.claudeAiOauth; + if (!loaded || !oauth) return null; - const oauth = creds.claudeAiOauth; + const { creds, source } = loaded; const now = Date.now(); // Check if token is still valid (with buffer) @@ -95,7 +214,7 @@ async function getValidToken(): Promise { const refreshed = await refreshOAuthToken(oauth.refreshToken); if (refreshed?.accessToken) { creds.claudeAiOauth = { ...oauth, ...refreshed }; - await writeCredentials(creds); + await saveCredentials(creds, source); return refreshed.accessToken.trim(); } } @@ -105,17 +224,71 @@ async function getValidToken(): Promise { } async function forceRefresh(): Promise { - const creds = await readCredentials(); - const refreshToken = creds?.claudeAiOauth?.refreshToken; - if (!refreshToken) return null; + const loaded = await loadCredentials(); + const oauth = loaded?.creds.claudeAiOauth; + if (!loaded || !oauth?.refreshToken) return null; - const refreshed = await refreshOAuthToken(refreshToken); - if (refreshed?.accessToken && creds?.claudeAiOauth) { - creds.claudeAiOauth = { ...creds.claudeAiOauth, ...refreshed }; - await writeCredentials(creds); - return refreshed.accessToken; + const refreshed = await refreshOAuthToken(oauth.refreshToken); + if (!refreshed?.accessToken) return null; + + loaded.creds.claudeAiOauth = { ...oauth, ...refreshed }; + await saveCredentials(loaded.creds, loaded.source); + return refreshed.accessToken.trim(); +} + +/** + * Recover from an upstream 401, in the order that costs least. + * + * Re-reading comes FIRST, and on macOS that is the whole fix: when a token has genuinely been revoked, + * the machine usually already holds a good one — Claude Code refreshed it into the Keychain minutes + * ago and never touched our copy. Spending our own refresh token in that situation is not just + * wasteful, it is how the two stores got out of step to begin with. + * + * Only when nobody else has moved do we refresh ourselves. + */ +async function recoverFromUnauthorized(usedToken: string): Promise { + const loaded = await loadCredentials(); + const current = loaded?.creds.claudeAiOauth?.accessToken?.trim(); + if (current && current !== usedToken) { + console.log('[claude:proxy] 401 — a newer token was already on disk, retrying with it'); + return current; } - return null; + console.log('[claude:proxy] 401 — refreshing'); + return forceRefresh(); +} + +/** + * Keep a valid token ahead of the next request, so nobody discovers an expired one by being told to + * wait three minutes for a chat that then fails to authenticate. + * + * Runs once at start-up and every {@link WATCHDOG_INTERVAL_MS} after. Every failure is logged and + * swallowed: a refresh that cannot happen must not take the proxy down with it, because the token on + * hand may still have hours left. + */ +export function startCredentialWatchdog(): void { + const tick = async () => { + const loaded = await loadCredentials(); + const oauth = loaded?.creds.claudeAiOauth; + if (!loaded || !oauth?.refreshToken) return; + + const remaining = (oauth.expiresAt ?? 0) - Date.now(); + if (remaining > WATCHDOG_REFRESH_WITHIN_MS) return; + + const refreshed = await refreshOAuthToken(oauth.refreshToken); + if (!refreshed?.accessToken) { + console.error('[claude:proxy] watchdog refresh failed — the next request will try again'); + return; + } + loaded.creds.claudeAiOauth = { ...oauth, ...refreshed }; + await saveCredentials(loaded.creds, loaded.source); + console.log(`[claude:proxy] watchdog refreshed the token from the ${loaded.source}`); + }; + + const run = () => { + tick().catch((err) => console.error('[claude:proxy] watchdog error:', err)); + }; + run(); + setInterval(run, WATCHDOG_INTERVAL_MS).unref?.(); } export function getProxySecret(): string { @@ -199,39 +372,30 @@ export function startAnthropicProxy() { body, }); - // If upstream returns 401, try one refresh and retry + // If upstream returns 401, recover the credential and retry once if (upstreamRes.status === 401) { - const creds = await readCredentials(); - const refreshToken = creds?.claudeAiOauth?.refreshToken; - if (refreshToken) { - const refreshed = await refreshOAuthToken(refreshToken); - if (refreshed?.accessToken) { - if (creds?.claudeAiOauth) { - creds.claudeAiOauth = { ...creds.claudeAiOauth, ...refreshed }; - await writeCredentials(creds); - } + const recovered = await recoverFromUnauthorized(token); + if (recovered) { + headers.set('Authorization', `Bearer ${recovered}`); + const retryBody = body ? new Uint8Array(body) : null; + const retryRes = await fetch(upstream, { + method: req.method, + headers, + body: retryBody, + }); - headers.set('Authorization', `Bearer ${refreshed.accessToken.trim()}`); - const retryBody = body ? new Uint8Array(body) : null; - const retryRes = await fetch(upstream, { - method: req.method, - headers, - body: retryBody, - }); - - const retryHeaders = new Headers(); - for (const [key, value] of retryRes.headers) { - const lower = key.toLowerCase(); - if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue; - retryHeaders.set(key, value); - } - - return new Response(retryRes.body, { - status: retryRes.status, - statusText: retryRes.statusText, - headers: retryHeaders, - }); + const retryHeaders = new Headers(); + for (const [key, value] of retryRes.headers) { + const lower = key.toLowerCase(); + if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue; + retryHeaders.set(key, value); } + + return new Response(retryRes.body, { + status: retryRes.status, + statusText: retryRes.statusText, + headers: retryHeaders, + }); } } @@ -252,4 +416,6 @@ export function startAnthropicProxy() { }); console.log(`[claude:proxy] listening on 127.0.0.1:${PROXY_PORT}`); + if (IS_MACOS) console.log(`[claude:proxy] macOS — reading credentials from the "${KEYCHAIN_SERVICE}" Keychain item`); + startCredentialWatchdog(); }