import { join } from 'node:path'; import { homedir, userInfo } from 'node:os'; import { getState, updateState } from './state'; const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051'); const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json'); const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; 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; expiresAt?: number; }; type CredentialsFile = { claudeAiOauth?: OAuthCredentials; }; async function readCredentials(): Promise { try { const file = Bun.file(CREDENTIALS_PATH); if (!(await file.exists())) return null; return (await file.json()) as CredentialsFile; } catch { return null; } } async function writeCredentials(creds: CredentialsFile): Promise { try { await Bun.write(CREDENTIALS_PATH, JSON.stringify(creds, null, 2)); } catch (err) { console.error('[claude:proxy] failed to write credentials:', err); } } /** 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...'); const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }), }); if (!res.ok) { const text = await res.text(); console.error(`[claude:proxy] token refresh failed (${res.status}): ${text}`); return null; } const data = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number }; if (!data.access_token) { console.error('[claude:proxy] token refresh response missing access_token'); return null; } const expiresAt = data.expires_in ? Date.now() + data.expires_in * 1000 : undefined; console.log(`[claude:proxy] token refreshed, expires in ${data.expires_in ?? '?'}s`); return { accessToken: data.access_token, refreshToken: data.refresh_token ?? refreshToken, expiresAt, }; } catch (err) { console.error('[claude:proxy] token refresh error:', err); return null; } } async function getValidToken(): Promise { const loaded = await loadCredentials(); const oauth = loaded?.creds.claudeAiOauth; if (!loaded || !oauth) return null; const { creds, source } = loaded; const now = Date.now(); // Check if token is still valid (with buffer) if (oauth.accessToken && oauth.expiresAt && oauth.expiresAt - now > EXPIRY_BUFFER_MS) { return oauth.accessToken.trim(); } // Token expired or about to expire — try refresh if (oauth.refreshToken) { const refreshed = await refreshOAuthToken(oauth.refreshToken); if (refreshed?.accessToken) { creds.claudeAiOauth = { ...oauth, ...refreshed }; await saveCredentials(creds, source); return refreshed.accessToken.trim(); } } // Fallback: return current token even if potentially expired (let upstream decide) return oauth.accessToken?.trim() || null; } async function forceRefresh(): Promise { const loaded = await loadCredentials(); const oauth = loaded?.creds.claudeAiOauth; if (!loaded || !oauth?.refreshToken) return null; 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; } 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 { return getState().proxySecret; } export function ensureProxySecret(): string { const state = getState(); if (state.proxySecret) return state.proxySecret; // Generate once, persist forever const secret = `sk-ant-api03-${crypto.randomUUID()}`; updateState({ proxySecret: secret }); return secret; } export function startAnthropicProxy() { const secret = ensureProxySecret(); Bun.serve({ port: PROXY_PORT, hostname: '127.0.0.1', idleTimeout: 0, async fetch(req) { const url = new URL(req.url); // Internal refresh endpoint (no auth needed — localhost only) if (url.pathname === '/refresh') { const token = await forceRefresh(); if (token) { return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ error: 'Refresh failed' }), { status: 502, headers: { 'Content-Type': 'application/json' }, }); } // Validate proxy secret const incomingKey = req.headers.get('x-api-key'); if (incomingKey !== secret) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Get valid token (refreshes if needed) const token = await getValidToken(); if (!token) { return new Response(JSON.stringify({ error: 'No OAuth token available' }), { status: 502, headers: { 'Content-Type': 'application/json' }, }); } // Build upstream URL const upstream = `${ANTHROPIC_API_BASE}${url.pathname}${url.search}`; // Clone headers, swap proxy secret for the OAuth bearer token. Anthropic // only accepts Claude Pro/Max OAuth tokens via Authorization + the oauth beta header. // Preserve any anthropic-beta values Claude Code sent (context-management, etc.) // and append oauth-2025-04-20 alongside them. const headers = new Headers(req.headers); headers.delete('x-api-key'); headers.set('Authorization', `Bearer ${token}`); const existingBeta = headers.get('anthropic-beta'); const betas = existingBeta ? existingBeta.split(',').map((s) => s.trim()).filter(Boolean) : []; if (!betas.includes('oauth-2025-04-20')) betas.push('oauth-2025-04-20'); headers.set('anthropic-beta', betas.join(',')); headers.delete('host'); // Read request body fully before forwarding const body = req.body ? await req.arrayBuffer() : null; // Forward request const upstreamRes = await fetch(upstream, { method: req.method, headers, body, }); // If upstream returns 401, recover the credential and retry once if (upstreamRes.status === 401) { 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, }); 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, }); } } // Build clean response headers const resHeaders = new Headers(); for (const [key, value] of upstreamRes.headers) { const lower = key.toLowerCase(); if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue; resHeaders.set(key, value); } return new Response(upstreamRes.body, { status: upstreamRes.status, statusText: upstreamRes.statusText, headers: resHeaders, }); }, }); 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(); }