From 69b41415cd4991406d429f2d2fa10461b4ae9c9c Mon Sep 17 00:00:00 2001 From: George Szulc Date: Sun, 25 Jan 2026 23:08:50 -0800 Subject: [PATCH] fix(auth): sync from Claude CLI keychain before OAuth refresh (#2036) Fixes the OAuth token race condition where Claude Code CLI and Clawdbot share credentials. When Claude Code refreshes its tokens first, it invalidates the old refresh token that Clawdbot has stored, causing refresh failures. Changes: - Before attempting OAuth refresh for the claude-cli profile, check if the Claude Code CLI keychain has fresher valid tokens - If keychain has valid tokens, use those instead of refreshing - On refresh failure, retry reading from keychain as a fallback - Update the auth store with keychain credentials for future use This ensures Clawdbot can always use the latest credentials from Claude Code CLI, even when the CLI refreshed its tokens independently. Fixes #2036 Co-Authored-By: Claude Opus 4.5 --- src/agents/auth-profiles/oauth.ts | 64 ++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 8c59a3044..5997cbb3c 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -4,8 +4,8 @@ import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../../config/config.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; -import { writeClaudeCliCredentials } from "../cli-credentials.js"; -import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js"; +import { readClaudeCliCredentials, writeClaudeCliCredentials } from "../cli-credentials.js"; +import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID, log } from "./constants.js"; import { formatAuthDoctorHint } from "./doctor.js"; import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; @@ -46,6 +46,37 @@ async function refreshOAuthTokenWithLock(params: { }; } + // Fix for OAuth race condition (#2036): Before attempting refresh with potentially + // stale credentials, check if Claude Code CLI keychain has fresher tokens. + // This prevents failures when Claude Code CLI has already refreshed the token + // (invalidating the old refresh token) but Clawdbot hasn't synced yet. + if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") { + const keychainCreds = readClaudeCliCredentials({ allowKeychainPrompt: true }); + if (keychainCreds?.type === "oauth" && keychainCreds.expires > Date.now()) { + log.info("using fresher credentials from claude cli keychain instead of refreshing", { + profileId: params.profileId, + keychainExpires: new Date(keychainCreds.expires).toISOString(), + storedExpires: new Date(cred.expires).toISOString(), + }); + const newCredentials: OAuthCredentials = { + access: keychainCreds.access, + refresh: keychainCreds.refresh, + expires: keychainCreds.expires, + }; + // Update store with keychain credentials + store.profiles[params.profileId] = { + ...cred, + ...newCredentials, + type: "oauth", + }; + saveAuthProfileStore(store, params.agentDir); + return { + apiKey: buildOAuthApiKey(cred.provider, newCredentials), + newCredentials, + }; + } + } + const oauthCreds: Record = { [cred.provider]: cred, }; @@ -184,6 +215,35 @@ export async function resolveApiKeyForProfile(params: { email: refreshed.email ?? cred.email, }; } + + // Fix for OAuth race condition (#2036): When refresh fails, try one more time + // to read fresher credentials from Claude Code CLI keychain. This handles the case + // where Claude Code refreshed its tokens (invalidating our refresh token) between + // when we checked and when we tried to refresh. + if (profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") { + const keychainCreds = readClaudeCliCredentials({ allowKeychainPrompt: true }); + if (keychainCreds?.type === "oauth" && keychainCreds.expires > Date.now()) { + log.info("refresh failed but found valid credentials in claude cli keychain", { + profileId, + keychainExpires: new Date(keychainCreds.expires).toISOString(), + }); + // Update store with keychain credentials for future use + refreshedStore.profiles[profileId] = { + ...cred, + access: keychainCreds.access, + refresh: keychainCreds.refresh, + expires: keychainCreds.expires, + type: "oauth", + }; + saveAuthProfileStore(refreshedStore, params.agentDir); + return { + apiKey: keychainCreds.access, + provider: cred.provider, + email: cred.email, + }; + } + } + const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({ cfg, store: refreshedStore,