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:
parent
a7534dc223
commit
351d778099
@ -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.
|
||||
*
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user