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 <noreply@anthropic.com>
This commit is contained in:
George Szulc 2026-01-25 23:08:50 -08:00
parent 6859e1e6a6
commit 69b41415cd

View File

@ -4,8 +4,8 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js"; import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
import { writeClaudeCliCredentials } from "../cli-credentials.js"; import { readClaudeCliCredentials, writeClaudeCliCredentials } from "../cli-credentials.js";
import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js"; import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID, log } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js"; import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.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<string, OAuthCredentials> = { const oauthCreds: Record<string, OAuthCredentials> = {
[cred.provider]: cred, [cred.provider]: cred,
}; };
@ -184,6 +215,35 @@ export async function resolveApiKeyForProfile(params: {
email: refreshed.email ?? cred.email, 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({ const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({
cfg, cfg,
store: refreshedStore, store: refreshedStore,