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 59e8a77e8..681679f3b 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 { OpenClawConfig } 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 {