From 351d778099fc32feea3206169136267e06df123a Mon Sep 17 00:00:00 2001 From: Kira Date: Wed, 28 Jan 2026 17:48:47 -0500 Subject: [PATCH] feat(auth): auto-sync CLI credentials on OAuth refresh failure When OAuth token refresh fails for anthropic:claude-cli or openai-codex:codex-cli profiles, attempt to recover by syncing fresh credentials from the external CLI tools (Claude Code CLI or Codex CLI). This handles the common case where Clawdbot and the CLI tool race to refresh tokens - one wins, and the other's refresh token gets revoked. Previously this required manual intervention (running `clawdbot models status`). Now recovery is automatic. Fixes #3615 --- src/agents/auth-profiles/external-cli-sync.ts | 128 +++++++++++++++++- src/agents/auth-profiles/oauth.ts | 52 ++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index d1fa31f23..082aae6cd 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -1,5 +1,11 @@ -import { readQwenCliCredentialsCached } from "../cli-credentials.js"; import { + readClaudeCliCredentialsCached, + readCodexCliCredentialsCached, + readQwenCliCredentialsCached, +} from "../cli-credentials.js"; +import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, EXTERNAL_CLI_NEAR_EXPIRY_MS, EXTERNAL_CLI_SYNC_TTL_MS, QWEN_CLI_PROFILE_ID, @@ -22,16 +28,132 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr ); } -function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean { +function isExternalProfileFresh( + cred: AuthProfileCredential | undefined, + now: number, + provider?: string, +): boolean { if (!cred) return false; if (cred.type !== "oauth" && cred.type !== "token") return false; - if (cred.provider !== "qwen-portal") { + if (provider && cred.provider !== provider) { return false; } if (typeof cred.expires !== "number") return true; return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS; } +/** + * Sync Claude Code CLI credentials into the store. + * Called on refresh failure to attempt recovery. + * + * Returns the synced credential if successful, null otherwise. + */ +export function trySyncClaudeCliCredentialsOnRefreshFailure( + store: AuthProfileStore, +): OAuthCredential | null { + const now = Date.now(); + const creds = readClaudeCliCredentialsCached({ ttlMs: 0 }); // Force fresh read + + if (!creds) { + log.debug("no claude cli credentials found for recovery sync"); + return null; + } + + // Only sync if CLI credentials are fresh (not expired) + if (creds.expires <= now) { + log.debug("claude cli credentials are expired, skipping recovery sync", { + expires: new Date(creds.expires).toISOString(), + }); + return null; + } + + const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + + // Check if CLI credentials are newer than what we have + if (existingOAuth && existingOAuth.expires >= creds.expires) { + log.debug("stored credentials are not older than claude cli, skipping recovery sync"); + return null; + } + + // Convert Claude CLI credential to OAuthCredential format + if (creds.type === "oauth") { + const oauthCred: OAuthCredential = { + type: "oauth", + provider: "anthropic", + access: creds.access, + refresh: creds.refresh, + expires: creds.expires, + }; + + if (!shallowEqualOAuthCredentials(existingOAuth, oauthCred)) { + store.profiles[CLAUDE_CLI_PROFILE_ID] = oauthCred; + log.info("synced anthropic credentials from claude cli after refresh failure", { + profileId: CLAUDE_CLI_PROFILE_ID, + expires: new Date(creds.expires).toISOString(), + }); + return oauthCred; + } + } + + return null; +} + +/** + * Sync Codex CLI credentials into the store. + * Called on refresh failure to attempt recovery. + * + * Returns the synced credential if successful, null otherwise. + */ +export function trySyncCodexCliCredentialsOnRefreshFailure( + store: AuthProfileStore, +): OAuthCredential | null { + const now = Date.now(); + const creds = readCodexCliCredentialsCached({ ttlMs: 0 }); // Force fresh read + + if (!creds) { + log.debug("no codex cli credentials found for recovery sync"); + return null; + } + + // Only sync if CLI credentials are fresh (not expired) + if (creds.expires <= now) { + log.debug("codex cli credentials are expired, skipping recovery sync", { + expires: new Date(creds.expires).toISOString(), + }); + return null; + } + + const existing = store.profiles[CODEX_CLI_PROFILE_ID]; + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + + // Check if CLI credentials are newer than what we have + if (existingOAuth && existingOAuth.expires >= creds.expires) { + log.debug("stored credentials are not older than codex cli, skipping recovery sync"); + return null; + } + + const oauthCred: OAuthCredential = { + type: "oauth", + provider: creds.provider, + access: creds.access, + refresh: creds.refresh, + expires: creds.expires, + accountId: creds.accountId, + }; + + if (!shallowEqualOAuthCredentials(existingOAuth, oauthCred)) { + store.profiles[CODEX_CLI_PROFILE_ID] = oauthCred; + log.info("synced openai-codex credentials from codex cli after refresh failure", { + profileId: CODEX_CLI_PROFILE_ID, + expires: new Date(creds.expires).toISOString(), + }); + return oauthCred; + } + + return null; +} + /** * Sync OAuth credentials from external CLI tools (Qwen Code CLI) into the store. * diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 590a752a3..016b7229c 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -4,8 +4,12 @@ import lockfile from "proper-lockfile"; import type { MoltbotConfig } from "../../config/config.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; -import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js"; +import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID, log } from "./constants.js"; import { formatAuthDoctorHint } from "./doctor.js"; +import { + trySyncClaudeCliCredentialsOnRefreshFailure, + trySyncCodexCliCredentialsOnRefreshFailure, +} from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js"; @@ -197,6 +201,52 @@ export async function resolveApiKeyForProfile(params: { } } + // Fallback: try syncing from external CLI tools (Claude Code CLI, Codex CLI) + // This handles the case where another tool refreshed the token and we have stale credentials + if (cred.provider === "anthropic" && profileId === CLAUDE_CLI_PROFILE_ID) { + try { + const synced = trySyncClaudeCliCredentialsOnRefreshFailure(refreshedStore); + if (synced && Date.now() < synced.expires) { + saveAuthProfileStore(refreshedStore, params.agentDir); + log.info("recovered from refresh failure by syncing Claude Code CLI credentials", { + profileId, + expires: new Date(synced.expires).toISOString(), + }); + return { + apiKey: buildOAuthApiKey(synced.provider, synced), + provider: synced.provider, + email: synced.email, + }; + } + } catch (syncError) { + log.debug("failed to sync claude cli credentials after refresh failure", { + error: syncError instanceof Error ? syncError.message : String(syncError), + }); + } + } + + if (cred.provider === "openai-codex" && profileId === CODEX_CLI_PROFILE_ID) { + try { + const synced = trySyncCodexCliCredentialsOnRefreshFailure(refreshedStore); + if (synced && Date.now() < synced.expires) { + saveAuthProfileStore(refreshedStore, params.agentDir); + log.info("recovered from refresh failure by syncing Codex CLI credentials", { + profileId, + expires: new Date(synced.expires).toISOString(), + }); + return { + apiKey: buildOAuthApiKey(synced.provider, synced), + provider: synced.provider, + email: synced.email, + }; + } + } catch (syncError) { + log.debug("failed to sync codex cli credentials after refresh failure", { + error: syncError instanceof Error ? syncError.message : String(syncError), + }); + } + } + // Fallback: if this is a secondary agent, try using the main agent's credentials if (params.agentDir) { try {