diff --git a/CHANGELOG.md b/CHANGELOG.md index 13970f99b6e..fb99dc42dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Agents/BTW: route `/btw` side questions through provider stream registration with the session workspace, so Ollama provider URL construction and workspace-scoped hooks apply correctly. Fixes #68336. (#70413) Thanks @suboss87. - Memory search: use sqlite-vec KNN for vector recall while preserving full post-filter result limits in multi-model indexes. Fixes #69666. (#69680) Thanks @aalekh-sarvam. +- Providers/OpenAI Codex: stop stale per-agent `openai-codex:default` OAuth profiles from shadowing a newer main-agent identity-scoped profile, and let `openclaw doctor` offer the matching cleanup. (#70393) Thanks @pashpashpash. - Codex harness: route Codex-tagged MCP tool approval elicitations through OpenClaw plugin approvals, including current empty-schema app-server requests, while leaving generic user-input prompts fail-closed. (#68807) Thanks @kesslerio. - WhatsApp/outbound: hold an in-memory active-delivery claim while a live outbound send is in flight, so a concurrent reconnect drain no longer re-drives the same pending queue entry and duplicates cron sends 7-12x after the 30-minute inbound-silence watchdog fires mid-delivery. Crash-replay of fresh queue entries left behind by a dead process is preserved because the claim is intentionally process-local. Fixes #70386. (#70428) Thanks @neeravmakwana. - Providers/SDK retry: cap long `Retry-After` sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1. diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index 2f7955b086e..dc37afa504a 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -125,6 +125,17 @@ describe("openai codex provider", () => { ); }); + it("declares the legacy default OAuth profile repair", () => { + const provider = buildOpenAICodexProviderPlugin(); + + expect(provider.oauthProfileIdRepairs).toEqual([ + { + legacyProfileId: "openai-codex:default", + promptLabel: "OpenAI Codex", + }, + ]); + }); + it("offers OpenAI menu auth methods for browser login and device pairing", () => { const provider = buildOpenAICodexProviderPlugin(); diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index f61fef8856f..b6ee97de8f5 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -374,6 +374,12 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { id: PROVIDER_ID, label: "OpenAI Codex", docsPath: "/providers/models", + oauthProfileIdRepairs: [ + { + legacyProfileId: "openai-codex:default", + promptLabel: "OpenAI Codex", + }, + ], auth: [ { id: "oauth", diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts index 3abdc46c186..69f936d928e 100644 --- a/extensions/openai/provider-contract-api.ts +++ b/extensions/openai/provider-contract-api.ts @@ -12,6 +12,12 @@ export function createOpenAICodexProvider(): ProviderPlugin { id: "openai-codex", label: "OpenAI Codex", docsPath: "/providers/models", + oauthProfileIdRepairs: [ + { + legacyProfileId: "openai-codex:default", + promptLabel: "OpenAI Codex", + }, + ], auth: [ { id: "oauth", diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 04209f6e674..fae102e77ec 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -7,6 +7,7 @@ import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, loadAuthProfileStoreForRuntime, + saveAuthProfileStore, } from "./auth-profiles.js"; import { AUTH_STORE_VERSION, log } from "./auth-profiles/constants.js"; import type { AuthProfileCredential } from "./auth-profiles/types.js"; @@ -57,6 +58,7 @@ vi.mock("./cli-credentials.js", () => ({ describe("ensureAuthProfileStore", () => { afterEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); resolveExternalAuthProfilesWithPluginsMock.mockReset(); resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); }); @@ -230,6 +232,347 @@ describe("ensureAuthProfileStore", () => { } }); + it("uses the main agent's newer OAuth profile when an agent still has a stale default profile", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-")); + const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + try { + const mainDir = path.join(root, "main-agent"); + const agentDir = path.join(root, "agent-x"); + fs.mkdirSync(mainDir, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + process.env.OPENCLAW_AGENT_DIR = mainDir; + process.env.PI_CODING_AGENT_DIR = mainDir; + + const freshProfileId = "openai-codex:user@example.com"; + const staleProfileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [freshProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "main-access", + refresh: "main-refresh", + expires: Date.now() + 60 * 60 * 1000, + email: "user@example.com", + }, + }, + order: { + "openai-codex": [freshProfileId], + }, + lastGood: { + "openai-codex": freshProfileId, + }, + }, + mainDir, + ); + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [freshProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "stale-identity-access", + refresh: "stale-identity-refresh", + expires: Date.now() - 30 * 60 * 1000, + email: "user@example.com", + }, + [staleProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() - 60 * 60 * 1000, + accountId: "acct-from-old-codex-auth", + }, + }, + order: { + "openai-codex": [staleProfileId], + }, + lastGood: { + "openai-codex": staleProfileId, + }, + usageStats: { + [staleProfileId]: { + lastUsed: Date.now() - 30_000, + errorCount: 3, + }, + }, + }, + agentDir, + ); + clearRuntimeAuthProfileStoreSnapshots(); + + const store = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); + + expect(store.profiles[freshProfileId]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "main-access", + refresh: "main-refresh", + }); + expect(store.profiles[staleProfileId]).toBeUndefined(); + expect(store.order?.["openai-codex"]).toEqual([freshProfileId]); + expect(store.lastGood?.["openai-codex"]).toBe(freshProfileId); + expect(store.usageStats?.[staleProfileId]).toBeUndefined(); + + const persistedAgentStore = JSON.parse( + fs.readFileSync(path.join(agentDir, "auth-profiles.json"), "utf8"), + ) as { profiles: Record }; + expect(persistedAgentStore.profiles[staleProfileId]).toBeDefined(); + } finally { + restoreAgentDirEnv({ previousAgentDir, previousPiAgentDir }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps a newer agent replacement credential while repairing stale default references", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-newer-agent-")); + const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + try { + const mainDir = path.join(root, "main-agent"); + const agentDir = path.join(root, "agent-x"); + fs.mkdirSync(mainDir, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + process.env.OPENCLAW_AGENT_DIR = mainDir; + process.env.PI_CODING_AGENT_DIR = mainDir; + + const freshProfileId = "openai-codex:user@example.com"; + const staleProfileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [freshProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "older-main-access", + refresh: "older-main-refresh", + expires: Date.now() + 30 * 60 * 1000, + email: "user@example.com", + }, + }, + order: { + "openai-codex": [freshProfileId], + }, + }, + mainDir, + ); + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [freshProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "newer-agent-access", + refresh: "newer-agent-refresh", + expires: Date.now() + 90 * 60 * 1000, + email: "user@example.com", + }, + [staleProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() - 60 * 60 * 1000, + email: "user@example.com", + }, + }, + order: { + "openai-codex": [staleProfileId], + }, + lastGood: { + "openai-codex": staleProfileId, + }, + }, + agentDir, + ); + clearRuntimeAuthProfileStoreSnapshots(); + + const store = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); + + expect(store.profiles[freshProfileId]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "newer-agent-access", + refresh: "newer-agent-refresh", + }); + expect(store.profiles[staleProfileId]).toBeUndefined(); + expect(store.order?.["openai-codex"]).toEqual([freshProfileId]); + expect(store.lastGood?.["openai-codex"]).toBe(freshProfileId); + } finally { + restoreAgentDirEnv({ previousAgentDir, previousPiAgentDir }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("preserves a valid main default OAuth profile while replacing a stale agent override", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-base-default-")); + const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + try { + const mainDir = path.join(root, "main-agent"); + const agentDir = path.join(root, "agent-x"); + fs.mkdirSync(mainDir, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + process.env.OPENCLAW_AGENT_DIR = mainDir; + process.env.PI_CODING_AGENT_DIR = mainDir; + + const freshProfileId = "openai-codex:user@example.com"; + const defaultProfileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [freshProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "main-access", + refresh: "main-refresh", + expires: Date.now() + 60 * 60 * 1000, + email: "user@example.com", + }, + [defaultProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "main-default-access", + refresh: "main-default-refresh", + expires: Date.now() + 45 * 60 * 1000, + }, + }, + order: { + "openai-codex": [freshProfileId, defaultProfileId], + }, + usageStats: { + [defaultProfileId]: { + lastUsed: 123, + }, + }, + }, + mainDir, + ); + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [defaultProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "stale-agent-default-access", + refresh: "stale-agent-default-refresh", + expires: Date.now() - 60 * 60 * 1000, + }, + }, + order: { + "openai-codex": [defaultProfileId], + }, + usageStats: { + [defaultProfileId]: { + lastUsed: 999, + errorCount: 2, + }, + }, + }, + agentDir, + ); + clearRuntimeAuthProfileStoreSnapshots(); + + const store = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); + + expect(store.order?.["openai-codex"]).toEqual([freshProfileId]); + expect(store.profiles[defaultProfileId]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "main-default-access", + }); + expect(store.usageStats?.[defaultProfileId]).toMatchObject({ + lastUsed: 123, + }); + } finally { + restoreAgentDirEnv({ previousAgentDir, previousPiAgentDir }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps a stale default OAuth profile when the main profile belongs to a different identity", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-drift-mismatch-")); + const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + try { + const mainDir = path.join(root, "main-agent"); + const agentDir = path.join(root, "agent-x"); + fs.mkdirSync(mainDir, { recursive: true }); + fs.mkdirSync(agentDir, { recursive: true }); + + process.env.OPENCLAW_AGENT_DIR = mainDir; + process.env.PI_CODING_AGENT_DIR = mainDir; + + const freshProfileId = "openai-codex:user@example.com"; + const staleProfileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [freshProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "main-access", + refresh: "main-refresh", + expires: Date.now() + 60 * 60 * 1000, + email: "user@example.com", + }, + }, + }, + mainDir, + ); + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + [staleProfileId]: { + type: "oauth", + provider: "openai-codex", + access: "other-access", + refresh: "other-refresh", + expires: Date.now() - 60 * 60 * 1000, + email: "other@example.com", + }, + }, + order: { + "openai-codex": [staleProfileId], + }, + lastGood: { + "openai-codex": staleProfileId, + }, + }, + agentDir, + ); + clearRuntimeAuthProfileStoreSnapshots(); + + const store = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); + + expect(store.profiles[freshProfileId]).toBeDefined(); + expect(store.profiles[staleProfileId]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "other-access", + }); + expect(store.order?.["openai-codex"]).toEqual([staleProfileId]); + expect(store.lastGood?.["openai-codex"]).toBe(staleProfileId); + } finally { + restoreAgentDirEnv({ previousAgentDir, previousPiAgentDir }); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it.each([ { name: "mode/apiKey aliases map to type/key", diff --git a/src/agents/auth-profiles/oauth-shared.ts b/src/agents/auth-profiles/oauth-shared.ts index 32c5d797369..964bdddb2ef 100644 --- a/src/agents/auth-profiles/oauth-shared.ts +++ b/src/agents/auth-profiles/oauth-shared.ts @@ -59,16 +59,18 @@ export function hasUsableOAuthCredential( return hasUsableStoredOAuthCredential(credential, { now }); } -function normalizeAuthIdentityToken(value: string | undefined): string | undefined { +export function normalizeAuthIdentityToken(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } -function normalizeAuthEmailToken(value: string | undefined): string | undefined { +export function normalizeAuthEmailToken(value: string | undefined): string | undefined { return normalizeAuthIdentityToken(value)?.toLowerCase(); } -function hasOAuthIdentity(credential: Pick): boolean { +export function hasOAuthIdentity( + credential: Pick, +): boolean { return ( normalizeAuthIdentityToken(credential.accountId) !== undefined || normalizeAuthEmailToken(credential.email) !== undefined diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index cfc1a10aae8..ab44fe0b925 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -1,7 +1,15 @@ import { resolveOAuthPath } from "../../config/paths.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { loadJsonFile } from "../../infra/json-file.js"; +import { normalizeProviderId } from "../provider-id.js"; import { AUTH_STORE_VERSION, log } from "./constants.js"; +import { + hasOAuthIdentity, + hasUsableOAuthCredential, + isSafeToAdoptMainStoreOAuthIdentity, + normalizeAuthEmailToken, + normalizeAuthIdentityToken, +} from "./oauth-shared.js"; import { resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; import { coerceAuthProfileState, @@ -12,6 +20,7 @@ import type { AuthProfileCredential, AuthProfileSecretsStore, AuthProfileStore, + OAuthCredential, OAuthCredentials, } from "./types.js"; @@ -159,6 +168,200 @@ function mergeRecord( return { ...base, ...override }; } +function dedupeMergedProfileOrder(profileIds: string[]): string[] { + return Array.from(new Set(profileIds)); +} + +function hasComparableOAuthIdentityConflict( + existing: OAuthCredential, + candidate: OAuthCredential, +): boolean { + const existingAccountId = normalizeAuthIdentityToken(existing.accountId); + const candidateAccountId = normalizeAuthIdentityToken(candidate.accountId); + if ( + existingAccountId !== undefined && + candidateAccountId !== undefined && + existingAccountId !== candidateAccountId + ) { + return true; + } + + const existingEmail = normalizeAuthEmailToken(existing.email); + const candidateEmail = normalizeAuthEmailToken(candidate.email); + return ( + existingEmail !== undefined && candidateEmail !== undefined && existingEmail !== candidateEmail + ); +} + +function isLegacyDefaultOAuthProfile(profileId: string, credential: OAuthCredential): boolean { + return profileId === `${normalizeProviderId(credential.provider)}:default`; +} + +function isNewerUsableOAuthCredential( + existing: OAuthCredential, + candidate: OAuthCredential, +): boolean { + if (!hasUsableOAuthCredential(candidate)) { + return false; + } + if (!hasUsableOAuthCredential(existing)) { + return true; + } + return ( + Number.isFinite(candidate.expires) && + (!Number.isFinite(existing.expires) || candidate.expires > existing.expires) + ); +} + +function findMainStoreOAuthReplacement(params: { + base: AuthProfileStore; + legacyProfileId: string; + legacyCredential: OAuthCredential; +}): string | undefined { + const providerKey = normalizeProviderId(params.legacyCredential.provider); + const candidates = Object.entries(params.base.profiles) + .flatMap(([profileId, credential]): Array<[string, OAuthCredential]> => { + if ( + profileId === params.legacyProfileId || + credential.type !== "oauth" || + normalizeProviderId(credential.provider) !== providerKey + ) { + return []; + } + return [[profileId, credential]]; + }) + .filter(([, credential]) => isNewerUsableOAuthCredential(params.legacyCredential, credential)) + .toSorted(([leftId, leftCredential], [rightId, rightCredential]) => { + const leftExpires = Number.isFinite(leftCredential.expires) ? leftCredential.expires : 0; + const rightExpires = Number.isFinite(rightCredential.expires) ? rightCredential.expires : 0; + if (rightExpires !== leftExpires) { + return rightExpires - leftExpires; + } + return leftId.localeCompare(rightId); + }); + + const exactIdentityCandidates = candidates.filter(([, credential]) => + isSafeToAdoptMainStoreOAuthIdentity(params.legacyCredential, credential), + ); + if (exactIdentityCandidates.length > 0) { + if (!hasOAuthIdentity(params.legacyCredential) && exactIdentityCandidates.length > 1) { + return undefined; + } + return exactIdentityCandidates[0]?.[0]; + } + + if (hasUsableOAuthCredential(params.legacyCredential)) { + return undefined; + } + const fallbackCandidates = candidates.filter( + ([, credential]) => !hasComparableOAuthIdentityConflict(params.legacyCredential, credential), + ); + if (fallbackCandidates.length !== 1) { + return undefined; + } + return fallbackCandidates[0]?.[0]; +} + +function replaceMergedProfileReferences(params: { + store: AuthProfileStore; + base: AuthProfileStore; + replacements: Map; +}): AuthProfileStore { + const { store, base, replacements } = params; + if (replacements.size === 0) { + return store; + } + + const profiles = { ...store.profiles }; + for (const [legacyProfileId, replacementProfileId] of replacements) { + const baseCredential = base.profiles[legacyProfileId]; + if (baseCredential) { + profiles[legacyProfileId] = baseCredential; + } else { + delete profiles[legacyProfileId]; + } + const replacementBaseCredential = base.profiles[replacementProfileId]; + const replacementCredential = profiles[replacementProfileId]; + if ( + replacementBaseCredential && + (!replacementCredential || + (replacementCredential.type === "oauth" && + replacementBaseCredential.type === "oauth" && + isNewerUsableOAuthCredential(replacementCredential, replacementBaseCredential))) + ) { + profiles[replacementProfileId] = replacementBaseCredential; + } + } + + const order = store.order + ? Object.fromEntries( + Object.entries(store.order).map(([provider, profileIds]) => [ + provider, + dedupeMergedProfileOrder( + profileIds.map((profileId) => replacements.get(profileId) ?? profileId), + ), + ]), + ) + : undefined; + + const lastGood = store.lastGood + ? Object.fromEntries( + Object.entries(store.lastGood).map(([provider, profileId]) => [ + provider, + replacements.get(profileId) ?? profileId, + ]), + ) + : undefined; + + const usageStats = store.usageStats ? { ...store.usageStats } : undefined; + if (usageStats) { + for (const legacyProfileId of replacements.keys()) { + const baseStats = base.usageStats?.[legacyProfileId]; + if (baseStats) { + usageStats[legacyProfileId] = baseStats; + } else { + delete usageStats[legacyProfileId]; + } + } + } + + return { + ...store, + profiles, + ...(order && Object.keys(order).length > 0 ? { order } : { order: undefined }), + ...(lastGood && Object.keys(lastGood).length > 0 ? { lastGood } : { lastGood: undefined }), + ...(usageStats && Object.keys(usageStats).length > 0 + ? { usageStats } + : { usageStats: undefined }), + }; +} + +function reconcileMainStoreOAuthProfileDrift(params: { + base: AuthProfileStore; + override: AuthProfileStore; + merged: AuthProfileStore; +}): AuthProfileStore { + const replacements = new Map(); + for (const [profileId, credential] of Object.entries(params.override.profiles)) { + if (credential.type !== "oauth" || !isLegacyDefaultOAuthProfile(profileId, credential)) { + continue; + } + const replacementProfileId = findMainStoreOAuthReplacement({ + base: params.base, + legacyProfileId: profileId, + legacyCredential: credential, + }); + if (replacementProfileId) { + replacements.set(profileId, replacementProfileId); + } + } + return replaceMergedProfileReferences({ + store: params.merged, + base: params.base, + replacements, + }); +} + export function mergeAuthProfileStores( base: AuthProfileStore, override: AuthProfileStore, @@ -171,13 +374,14 @@ export function mergeAuthProfileStores( ) { return base; } - return { + const merged = { version: Math.max(base.version, override.version ?? base.version), profiles: { ...base.profiles, ...override.profiles }, order: mergeRecord(base.order, override.order), lastGood: mergeRecord(base.lastGood, override.lastGood), usageStats: mergeRecord(base.usageStats, override.usageStats), }; + return reconcileMainStoreOAuthProfileDrift({ base, override, merged }); } export function buildPersistedAuthProfileSecretsStore( diff --git a/src/commands/doctor-auth-legacy-oauth.ts b/src/commands/doctor-auth-legacy-oauth.ts index a33b3df90a4..22849357979 100644 --- a/src/commands/doctor-auth-legacy-oauth.ts +++ b/src/commands/doctor-auth-legacy-oauth.ts @@ -1,6 +1,7 @@ import { repairOAuthProfileIdMismatch } from "../agents/auth-profiles/repair.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; async function loadProviderRuntime() { @@ -15,6 +16,11 @@ function hasConfigOAuthProfiles(cfg: OpenClawConfig): boolean { return Object.values(cfg.auth?.profiles ?? {}).some((profile) => profile?.mode === "oauth"); } +function sanitizePromptLabel(label: string | undefined): string | undefined { + const sanitized = label ? sanitizeForLog(label).trim() : undefined; + return sanitized || undefined; +} + export async function maybeRepairLegacyOAuthProfileIds( cfg: OpenClawConfig, prompter: DoctorPrompter, @@ -47,8 +53,12 @@ export async function maybeRepairLegacyOAuthProfileIds( const { note } = await loadNoteRuntime(); note(repair.changes.map((c) => `- ${c}`).join("\n"), "Auth profiles"); + const label = + sanitizePromptLabel(repairSpec.promptLabel) ?? + sanitizePromptLabel(provider.label) ?? + provider.id; const apply = await prompter.confirm({ - message: `Update ${repairSpec.promptLabel ?? provider.label} OAuth profile id in config now?`, + message: `Update ${label} OAuth profile id in config now?`, initialValue: true, }); if (!apply) { diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts index 5b82da8d859..7d680ab3183 100644 --- a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -152,4 +152,53 @@ describe("maybeRepairLegacyOAuthProfileIds", () => { }); expect(next.auth?.order?.anthropic).toEqual(["anthropic:user@example.com"]); }); + + it("strips provider-controlled terminal escapes from repair prompts", async () => { + authProfileStoreMock.store = { + version: 1, + profiles: { + "anthropic:user@example.com": { + type: "oauth", + provider: "anthropic", + access: "token-a", + refresh: "token-r", + expires: Date.now() + 60_000, + email: "user@example.com", + }, + }, + }; + + resolvePluginProvidersMock.mockReturnValue([ + { + id: "anthropic", + label: "\u001b[31mAnthropic\u001b[0m", + auth: [], + oauthProfileIdRepairs: [ + { legacyProfileId: "anthropic:default", promptLabel: "\u001b[2JBad\u0007 Label" }, + ], + }, + ]); + repairMocks.repairOAuthProfileIdMismatch.mockReturnValue({ + migrated: true, + changes: ["Auth: migrate anthropic:default to anthropic:user@example.com"], + config: { auth: { profiles: {} } }, + }); + + const prompter = makePrompter(true); + await maybeRepairLegacyOAuthProfileIds( + { + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "oauth" }, + }, + }, + } as OpenClawConfig, + prompter, + ); + + expect(prompter.confirm).toHaveBeenCalledWith({ + message: "Update Bad Label OAuth profile id in config now?", + initialValue: true, + }); + }); });