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
This commit is contained in:
Kira 2026-01-28 17:48:47 -05:00
parent a7534dc223
commit 351d778099
2 changed files with 176 additions and 4 deletions

View File

@ -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.
*

View File

@ -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 {