From 46598a120fcda9a283c9aef588cbcd4dbdf9bc0a Mon Sep 17 00:00:00 2001 From: Eva Date: Thu, 2 Jul 2026 22:50:25 +0200 Subject: [PATCH] fix: OAuth refresh failures report reauth instead of stale success (#99134) Fail closed when managed OpenAI OAuth refresh fails instead of silently falling back to stale external Codex CLI credentials. Make managed provider OAuth authoritative after bootstrap, preserve API-key and non-OpenAI external CLI behavior, and surface targeted re-auth guidance without exposing profile IDs in group/channel replies. Fixes #99120. Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com> --- docs/concepts/oauth.md | 17 +- src/agents/auth-health.ts | 1 + .../auth-profiles.external-cli-sync.test.ts | 73 ++++++ .../auth-profiles/effective-oauth.test.ts | 3 + src/agents/auth-profiles/effective-oauth.ts | 7 +- src/agents/auth-profiles/external-cli-sync.ts | 42 +-- .../auth-profiles/external-oauth.test.ts | 3 + .../auth-profiles/oauth-manager.test.ts | 42 +++ src/agents/auth-profiles/oauth-manager.ts | 41 +-- .../oauth-refresh-failure.test.ts | 37 +++ .../auth-profiles/oauth-refresh-failure.ts | 61 ++++- ...auth.openai-codex-refresh-fallback.test.ts | 247 ++++++++++++++++-- src/agents/auth-profiles/oauth.ts | 17 +- src/agents/model-auth.ts | 51 ++++ .../reply/agent-runner-execution.test.ts | 99 ++++++- .../reply/agent-runner-execution.ts | 68 +++-- src/commands/doctor-auth.hints.test.ts | 13 + src/commands/doctor-auth.ts | 10 +- 18 files changed, 688 insertions(+), 144 deletions(-) diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index d3244e7b69a9..b27902fff917 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -55,9 +55,9 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**: - external CLI reuse is provider-specific: Codex CLI can bootstrap an empty `openai:default` profile, but once OpenClaw has a local OAuth profile, the local refresh token is canonical. If that local refresh token is rejected, - OpenClaw can use a usable same-account Codex CLI token as a runtime-only - fallback; other integrations can remain externally managed and re-read their - CLI auth store + OpenClaw reports the managed profile for re-authentication instead of using + Codex CLI token material as a sibling runtime fallback. Other integrations can + remain externally managed and re-read their CLI auth store - status and startup paths that already know the configured provider set scope external CLI discovery to that set, so an unrelated CLI login store is not probed for a single-provider setup @@ -166,11 +166,12 @@ At runtime: the secondary agent store - exception: some external CLI credentials stay externally managed; OpenClaw re-reads those CLI auth stores instead of spending copied refresh tokens. - Codex CLI bootstrap is intentionally narrower: it seeds an empty - `openai:default` profile, then OpenClaw-owned refreshes keep the local - profile canonical. If the local Codex refresh fails and Codex CLI has a - usable token for the same account, OpenClaw may use that token for the current - runtime request without writing it back to `auth-profiles.json`. + Codex CLI bootstrap is intentionally narrower: it can seed an empty + `openai:default` or explicitly requested OpenAI profile only before OpenClaw + owns OAuth for the provider. After that, OpenClaw-owned refreshes keep local + profiles canonical and discovery does not add Codex CLI auth in any sibling + slot. If a managed refresh fails, OpenClaw reports the affected profile for + re-authentication instead of returning external CLI token material. The refresh flow is automatic; you generally don't need to manage tokens manually. diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index 80381b966e08..5648b09cd595 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -232,6 +232,7 @@ function buildProfileHealth(params: { } const effectiveCredential = resolveEffectiveOAuthCredential({ + store, profileId, credential: healthCredential, allowKeychainPrompt, diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 757cdeaa2f84..f48ca39e4e02 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -300,6 +300,7 @@ describe("external cli oauth resolution", () => { ); const credential = readExternalCliBootstrapCredential({ + store: makeStore(), profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, credential: makeOAuthCredential({ provider: "openai" }), }); @@ -333,6 +334,77 @@ describe("external cli oauth resolution", () => { ); }); + it("does not add Codex CLI as a sibling to a named managed OpenAI profile", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai", + access: "codex-cli-access", + refresh: "codex-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + accountId: "acct-codex", + }), + ); + + const profiles = resolveExternalCliAuthProfiles( + makeStore( + "openai:user@example.com", + makeOAuthCredential({ + provider: "openai", + access: "managed-access", + refresh: "managed-refresh", + expires: Date.now() - 5_000, + accountId: "acct-codex", + }), + ), + { + providerIds: ["openai"], + }, + ); + + expect(profiles).toStrictEqual([]); + expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled(); + }); + + it("does not fill an empty default slot beside a named managed OpenAI profile", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai", + access: "codex-cli-access", + refresh: "codex-cli-refresh", + accountId: "acct-codex", + }), + ); + + const profiles = resolveExternalCliAuthProfiles( + { + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: { + type: "oauth", + provider: "openai", + access: "", + refresh: "", + expires: 0, + }, + "openai:user@example.com": makeOAuthCredential({ + provider: "openai", + access: "managed-access", + refresh: "managed-refresh", + expires: Date.now() - 5_000, + accountId: "acct-codex", + }), + }, + }, + { + providerIds: ["openai"], + profileIds: [OPENAI_CODEX_DEFAULT_PROFILE_ID], + }, + ); + + expect(profiles).toStrictEqual([]); + expect(mocks.readCodexCliCredentialsCached).not.toHaveBeenCalled(); + }); + it("keeps any existing default codex oauth over Codex CLI bootstrap credentials", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ @@ -366,6 +438,7 @@ describe("external cli oauth resolution", () => { ); const credential = readExternalCliBootstrapCredential({ + store: makeStore(), profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, credential: makeOAuthCredential({ provider: "anthropic" }), }); diff --git a/src/agents/auth-profiles/effective-oauth.test.ts b/src/agents/auth-profiles/effective-oauth.test.ts index e802f86cfd52..9db7681af87f 100644 --- a/src/agents/auth-profiles/effective-oauth.test.ts +++ b/src/agents/auth-profiles/effective-oauth.test.ts @@ -41,6 +41,7 @@ describe("resolveEffectiveOAuthCredential", () => { expect( resolveEffectiveOAuthCredential({ + store: { version: 1, profiles: {} }, profileId: "openai:default", credential: makeCredential(), }), @@ -62,6 +63,7 @@ describe("resolveEffectiveOAuthCredential", () => { expect( resolveEffectiveOAuthCredential({ + store: { version: 1, profiles: {} }, profileId: "openai:default", credential: local, }), @@ -79,6 +81,7 @@ describe("resolveEffectiveOAuthCredential", () => { expect( resolveEffectiveOAuthCredential({ + store: { version: 1, profiles: {} }, profileId: "openai:default", credential: local, }), diff --git a/src/agents/auth-profiles/effective-oauth.ts b/src/agents/auth-profiles/effective-oauth.ts index 4803a9031a34..06dc7a835727 100644 --- a/src/agents/auth-profiles/effective-oauth.ts +++ b/src/agents/auth-profiles/effective-oauth.ts @@ -5,19 +5,22 @@ */ import { readExternalCliBootstrapCredential } from "./external-cli-sync.js"; import { resolveEffectiveOAuthCredential as resolveManagedOAuthCredential } from "./oauth-manager.js"; -import type { OAuthCredential } from "./types.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; /** Resolves the effective OAuth credential, optionally reading external CLI bootstrap state. */ export function resolveEffectiveOAuthCredential(params: { + store: AuthProfileStore; profileId: string; credential: OAuthCredential; allowKeychainPrompt?: boolean; }): OAuthCredential { return resolveManagedOAuthCredential({ + store: params.store, profileId: params.profileId, credential: params.credential, - readBootstrapCredential: ({ profileId, credential }) => + readBootstrapCredential: ({ store, profileId, credential }) => readExternalCliBootstrapCredential({ + store, profileId, credential, allowKeychainPrompt: params.allowKeychainPrompt ?? false, diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index b4df4373a845..267a91eed0bd 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -174,8 +174,21 @@ function hasInlineOAuthTokenMaterial(credential: OAuthCredential): boolean { ); } +function hasManagedProviderOAuth( + store: AuthProfileStore, + providerConfig: ExternalCliSyncProvider, +): boolean { + return Object.values(store.profiles).some( + (credential) => + credential?.type === "oauth" && + listExternalCliProviderIds(providerConfig).includes(credential.provider) && + hasInlineOAuthTokenMaterial(credential), + ); +} + /** Read a CLI credential only for safe bootstrap of an unusable local profile. */ export function readExternalCliBootstrapCredential(params: { + store: AuthProfileStore; profileId: string; credential: OAuthCredential; allowInlineOAuthTokenMaterial?: boolean; @@ -185,6 +198,9 @@ export function readExternalCliBootstrapCredential(params: { if (!provider) { return null; } + if (provider.bootstrapOnly && hasManagedProviderOAuth(params.store, provider)) { + return null; + } if ( provider.bootstrapOnly && !params.allowInlineOAuthTokenMaterial && @@ -198,26 +214,6 @@ export function readExternalCliBootstrapCredential(params: { ); } -/** Read a CLI credential as a fallback for refresh/runtime auth recovery. */ -export function readExternalCliFallbackCredential(params: { - profileId: string; - credential: OAuthCredential; - allowKeychainPrompt?: boolean; -}): OAuthCredential | null { - const provider = - resolveExternalCliSyncProvider(params) ?? - EXTERNAL_CLI_SYNC_PROVIDERS.find((entry) => - listExternalCliProviderIds(entry).includes(params.credential.provider), - ); - if (!provider) { - return null; - } - return normalizeExternalCliCredentialProvider( - provider.readCredentials({ allowKeychainPrompt: params.allowKeychainPrompt }), - params.credential.provider, - ); -} - function normalizeProviderScope(values: Iterable | undefined): Set | undefined { if (values === undefined) { return undefined; @@ -278,6 +274,12 @@ function listScopedExternalCliProfileIds(params: { options?: ExternalCliAuthProfileOptions; }): string[] { const { options, providerConfig, store } = params; + // Bootstrap-only CLI state must not enter any sibling slot once OpenClaw + // owns OAuth for the provider, regardless of how discovery was scoped. + if (providerConfig.bootstrapOnly && hasManagedProviderOAuth(store, providerConfig)) { + return []; + } + const requestedProfileIds = Array.from(options?.profileIds ?? []) .map((value) => value.trim()) .filter((value) => value.length > 0); diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 987d9241a446..db8049f44c40 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -187,6 +187,9 @@ describe("auth external oauth helpers", () => { expect(overlaidProfile.refresh).toBe("fresh-cli-refresh-token"); expect(overlaidProfile.accountId).toBe("acct-cli"); const managedCredential = readExternalCliBootstrapCredential({ + store: createStore({ + "openai:default": tokenlessCredential, + }), profileId: "openai:default", credential: tokenlessCredential, }); diff --git a/src/agents/auth-profiles/oauth-manager.test.ts b/src/agents/auth-profiles/oauth-manager.test.ts index 32ef11e5373f..af367dacf67e 100644 --- a/src/agents/auth-profiles/oauth-manager.test.ts +++ b/src/agents/auth-profiles/oauth-manager.test.ts @@ -561,6 +561,48 @@ describe("createOAuthManager", () => { }); }); + it("fails closed after managed refresh failure", async () => { + await withOAuthAgentDirs("oauth-manager-refresh-fail-closed-", async ({ agentDir }) => { + const profileId = "openai:user@example.com"; + const managedCredential = createCredential({ + access: "managed-expired-access", + refresh: "managed-refresh", + expires: Date.now() - 60_000, + email: "user@example.com", + accountId: "acct-123", + }); + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: managedCredential, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + const manager = createOAuthManager({ + buildApiKey: async (_provider, credential) => credential.access, + refreshCredential: vi.fn(async () => { + throw new Error("refresh rejected managed profile"); + }), + readBootstrapCredential: () => null, + isRefreshTokenReusedError: () => false, + }); + + await expect( + manager.resolveOAuthAccess({ + store: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }), + profileId, + credential: managedCredential, + agentDir, + }), + ).rejects.toBeInstanceOf(OAuthManagerRefreshError); + }); + }); + it("redacts the external oauth credential attempted during refresh failures", async () => { await withOAuthTempRoot("oauth-manager-refresh-redact-", async (tempRoot) => { const agentDir = path.join(tempRoot, "agents", "sub", "agent"); diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 044baebfc377..f49b8245b320 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -18,7 +18,6 @@ import { } from "./oauth-refresh-lock-errors.js"; import { areOAuthCredentialsEquivalent, - hasMatchingOAuthIdentity, hasUsableOAuthCredential, isSafeToAdoptBootstrapOAuthIdentity, isSafeToAdoptMainStoreOAuthIdentity, @@ -46,10 +45,7 @@ export type OAuthManagerAdapter = { ) => Promise; refreshCredential: (credential: OAuthCredential) => Promise; readBootstrapCredential: (params: { - profileId: string; - credential: OAuthCredential; - }) => OAuthCredential | null; - readFallbackCredential?: (params: { + store: AuthProfileStore; profileId: string; credential: OAuthCredential; }) => OAuthCredential | null; @@ -63,7 +59,7 @@ export type ResolvedOAuthAccess = { /** Refresh failure that preserves a redacted refreshed store and credential. */ export class OAuthManagerRefreshError extends OAuthRefreshFailureError { - readonly profileId: string; + override readonly profileId: string; readonly code?: string; readonly lockPath?: string; readonly #refreshedStore: AuthProfileStore; @@ -93,6 +89,7 @@ export class OAuthManagerRefreshError extends OAuthRefreshFailureError { const causeMessage = formatRedactedOAuthRefreshError(params.cause, secrets); super({ provider: params.credential.provider, + profileId: params.profileId, message: `OAuth token refresh failed for ${params.credential.provider}: ${causeMessage}`, cause: createRedactedOAuthRefreshCause(delegatedCause, secrets), }); @@ -270,11 +267,13 @@ async function loadFreshStoredOAuthCredential(params: { /** Select local OAuth unless a safe external bootstrap credential should win. */ export function resolveEffectiveOAuthCredential(params: { + store: AuthProfileStore; profileId: string; credential: OAuthCredential; readBootstrapCredential: OAuthManagerAdapter["readBootstrapCredential"]; }): OAuthCredential { const imported = params.readBootstrapCredential({ + store: params.store, profileId: params.profileId, credential: params.credential, }); @@ -538,6 +537,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { } const externallyManaged = adapter.readBootstrapCredential({ + store, profileId: params.profileId, credential: cred, }); @@ -686,6 +686,7 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { credential: params.credential, }) ?? params.credential; const effectiveCredential = resolveEffectiveOAuthCredential({ + store: params.store, profileId: params.profileId, credential: adoptedCredential, readBootstrapCredential: adapter.readBootstrapCredential, @@ -806,34 +807,6 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { // keep the original refresh error below } } - const fallback = adapter.readFallbackCredential?.({ - profileId: params.profileId, - credential: effectiveCredential, - }); - if ( - fallback && - fallback.provider === params.credential.provider && - hasUsableOAuthCredential(fallback) && - hasMatchingOAuthIdentity(params.credential, fallback) && - canReuseOAuthCredentialAfterRefreshFailure({ - forceRefresh: params.forceRefresh, - attempted: effectiveCredential, - candidate: fallback, - }) - ) { - log.info("using external OAuth credential after refresh failure", { - profileId: params.profileId, - provider: fallback.provider, - expires: new Date(fallback.expires).toISOString(), - }); - return { - apiKey: await adapter.buildApiKey(fallback.provider, fallback, { - cfg: params.cfg, - agentDir: params.agentDir, - }), - credential: fallback, - }; - } throw new OAuthManagerRefreshError({ credential: params.credential, attemptedCredentials: [effectiveCredential, ...attemptedCredentials], diff --git a/src/agents/auth-profiles/oauth-refresh-failure.test.ts b/src/agents/auth-profiles/oauth-refresh-failure.test.ts index fc766a0a440c..816a72b2306b 100644 --- a/src/agents/auth-profiles/oauth-refresh-failure.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-failure.test.ts @@ -8,6 +8,7 @@ import { buildOAuthRefreshFailureLoginCommand, classifyOAuthRefreshFailure, classifyOAuthRefreshFailureError, + formatOAuthRefreshFailureLoginCommandMarkdown, OAuthRefreshFailureError, } from "./oauth-refresh-failure.js"; @@ -24,20 +25,56 @@ describe("oauth refresh failure hints", () => { ); }); + it("includes the profile id in refresh-failure login hints when known", () => { + expect( + buildOAuthRefreshFailureLoginCommand("openai", { + profileId: "Work Profile", + }), + ).toBe("openclaw models auth login --provider openai --profile-id 'Work Profile'"); + }); + + it("renders login commands containing backticks as valid Markdown code spans", () => { + const command = buildOAuthRefreshFailureLoginCommand("openai", { + profileId: "openai:work`slot", + }); + + expect(formatOAuthRefreshFailureLoginCommandMarkdown(command)).toBe( + "``openclaw models auth login --provider openai --profile-id 'openai:work`slot'``", + ); + }); + it("classifies typed refresh failures without parsing the display message", () => { expect( classifyOAuthRefreshFailureError( new OAuthRefreshFailureError({ provider: "openai", + profileId: "openai:user@example.com", message: "invalid_grant", }), ), ).toEqual({ provider: "openai", + profileId: "openai:user@example.com", reason: "invalid_grant", }); }); + it("classifies typed refresh failures through wrapper causes", () => { + const refreshError = new OAuthRefreshFailureError({ + provider: "openai", + profileId: "openai:user@example.com", + message: "invalid_grant", + }); + + expect(classifyOAuthRefreshFailureError(new Error("wrapped", { cause: refreshError }))).toEqual( + { + provider: "openai", + profileId: "openai:user@example.com", + reason: "invalid_grant", + }, + ); + }); + it("classifies token invalidation refresh failures", () => { expect( classifyOAuthRefreshFailure( diff --git a/src/agents/auth-profiles/oauth-refresh-failure.ts b/src/agents/auth-profiles/oauth-refresh-failure.ts index 1c39343cc53d..3cb01d3d9e0c 100644 --- a/src/agents/auth-profiles/oauth-refresh-failure.ts +++ b/src/agents/auth-profiles/oauth-refresh-failure.ts @@ -17,18 +17,21 @@ export type OAuthRefreshFailureReason = type OAuthRefreshFailure = { provider: string | null; + profileId?: string; reason: OAuthRefreshFailureReason | null; }; /** Error type that carries provider and classified OAuth refresh failure reason. */ export class OAuthRefreshFailureError extends Error { readonly provider: string; + readonly profileId?: string; readonly reason: OAuthRefreshFailureReason | null; - constructor(params: { provider: string; message: string; cause?: unknown }) { + constructor(params: { provider: string; profileId?: string; message: string; cause?: unknown }) { super(params.message, { cause: params.cause }); this.name = "OAuthRefreshFailureError"; this.provider = params.provider; + this.profileId = params.profileId; this.reason = classifyOAuthRefreshFailureReason(params.message); } } @@ -57,6 +60,27 @@ function sanitizeOAuthRefreshFailureProvider(provider: string | null | undefined return normalized && SAFE_PROVIDER_ID_RE.test(normalized) ? normalized : null; } +function sanitizeOAuthRefreshFailureProfileId(profileId: string | null | undefined): string | null { + const sanitized = profileId ? sanitizeForLog(profileId).trim() : ""; + return sanitized || null; +} + +function quoteShellArg(value: string): string { + const escaped = + process.platform === "win32" ? value.replaceAll("'", "''") : value.replaceAll("'", "'\\''"); + return `'${escaped}'`; +} + +/** Wrap a rendered login command in a Markdown code span that survives embedded backticks. */ +export function formatOAuthRefreshFailureLoginCommandMarkdown(command: string): string { + let fence = "`"; + while (command.includes(fence)) { + fence += "`"; + } + const padding = command.startsWith("`") || command.endsWith("`") ? " " : ""; + return `${fence}${padding}${command}${padding}${fence}`; +} + /** Classify a raw OAuth refresh failure message into a stable reason code. */ export function classifyOAuthRefreshFailureReason( message: string, @@ -96,19 +120,38 @@ export function classifyOAuthRefreshFailure(message: string): OAuthRefreshFailur /** Classify provider/reason from the structured OAuth refresh failure error. */ export function classifyOAuthRefreshFailureError(err: unknown): OAuthRefreshFailure | null { - if (!(err instanceof OAuthRefreshFailureError)) { - return null; + const seen = new Set(); + let candidate = err; + while (candidate && typeof candidate === "object") { + if (candidate instanceof OAuthRefreshFailureError) { + const profileId = sanitizeOAuthRefreshFailureProfileId(candidate.profileId); + return { + provider: sanitizeOAuthRefreshFailureProvider(candidate.provider), + ...(profileId ? { profileId } : {}), + reason: candidate.reason, + }; + } + if (seen.has(candidate)) { + return null; + } + seen.add(candidate); + candidate = (candidate as { cause?: unknown }).cause; } - return { - provider: sanitizeOAuthRefreshFailureProvider(err.provider), - reason: err.reason, - }; + return null; } /** Build the login command operators should run after OAuth refresh failure. */ -export function buildOAuthRefreshFailureLoginCommand(provider: string | null | undefined): string { +export function buildOAuthRefreshFailureLoginCommand( + provider: string | null | undefined, + options?: { profileId?: string | null }, +): string { const sanitizedProvider = sanitizeOAuthRefreshFailureProvider(provider); + const sanitizedProfileId = sanitizeOAuthRefreshFailureProfileId(options?.profileId); return sanitizedProvider - ? formatCliCommand(`openclaw models auth login --provider ${sanitizedProvider}`) + ? formatCliCommand( + sanitizedProfileId + ? `openclaw models auth login --provider ${sanitizedProvider} --profile-id ${quoteShellArg(sanitizedProfileId)}` + : `openclaw models auth login --provider ${sanitizedProvider}`, + ) : formatCliCommand("openclaw models auth login"); } diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 80764a9788dc..a52fc3a70c37 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -1,7 +1,7 @@ /** * Tests OpenAI/Codex OAuth refresh fallback behavior. - * Covers CLI bootstrap and profile success state when refresh recovery has to - * fall back across auth sources. + * Covers CLI bootstrap and ensures refresh failures fail closed instead of + * being masked by external CLI credentials. */ import fs from "node:fs/promises"; import os from "node:os"; @@ -23,6 +23,7 @@ import { import type { AuthProfileStore, OAuthCredential } from "./types.js"; let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; let resolveApiKeyForProvider: typeof import("../model-auth.js").resolveApiKeyForProvider; +let hasAvailableAuthForProvider: typeof import("../model-auth.js").hasAvailableAuthForProvider; let markAuthProfileSuccess: typeof import("./profiles.js").markAuthProfileSuccess; type GetOAuthApiKey = typeof import("../../llm/oauth.js").getOAuthApiKey; @@ -147,7 +148,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { beforeAll(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-refresh-fallback-")); ({ resolveApiKeyForProfile } = await import("./oauth.js")); - ({ resolveApiKeyForProvider } = await import("../model-auth.js")); + ({ hasAvailableAuthForProvider, resolveApiKeyForProvider } = await import("../model-auth.js")); ({ markAuthProfileSuccess } = await import("./profiles.js")); }); @@ -185,7 +186,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { await fs.rm(tempRoot, { recursive: true, force: true }); }); - it("falls back to matching cached Codex CLI credentials when openai refresh fails", async () => { + it("fails closed instead of using matching cached Codex CLI credentials when openai refresh fails", async () => { const profileId = "openai:default"; saveAuthProfileStore( createExpiredOauthStore({ @@ -205,18 +206,54 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { accountId: "acct-cached", }); - const result = await resolveApiKeyForProfile({ - store: ensureAuthProfileStore(agentDir), - profileId, + await expect( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + }), + ).rejects.toThrow(/OAuth token refresh failed for openai/); + expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); + }); + + it("does not fill an explicit empty default profile beside managed OpenAI OAuth", async () => { + const profileId = "openai:default"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai", + access: "", + refresh: "", + expires: 0, + }, + "openai:user@example.com": { + type: "oauth", + provider: "openai", + access: "managed-access-token", + refresh: "managed-refresh-token", + expires: Date.now() - 60_000, + accountId: "acct-managed", + }, + }, + }, agentDir, + { filterExternalAuthProfiles: false, syncExternalCli: false }, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai", + access: "codex-cli-access-token", + refresh: "codex-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-codex", }); - expect(result).toEqual({ - apiKey: "cached-access-token", // pragma: allowlist secret - provider: "openai", - email: undefined, - }); - expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); + await expect(resolveOpenAICodexProfile({ profileId, agentDir })).resolves.toBeNull(); + expect(readCodexCliCredentialsCachedMock).not.toHaveBeenCalled(); + expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); }); it("refreshes near-expiry openai credentials before hard expiry", async () => { @@ -481,7 +518,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { }); }); - it("uses same-account Codex CLI credentials after forced local refresh fails", async () => { + it("does not use same-account Codex CLI credentials after forced local refresh fails", async () => { const profileId = "openai:default"; saveAuthProfileStore( { @@ -520,16 +557,8 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { agentDir, forceRefresh: true, }), - ).resolves.toEqual({ - apiKey: "codex-cli-access-token", - provider: "openai", - email: undefined, - }); + ).rejects.toThrow(/OAuth token refresh failed for openai/); - expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledWith({ - ttlMs: expect.any(Number), - allowKeychainPrompt: false, - }); const persisted = await readPersistedStore(agentDir); const persistedProfile = requireOAuthProfile(persisted, profileId); expect(persistedProfile.accountId).toBe("acct-shared"); @@ -539,7 +568,58 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { expect(JSON.stringify(persisted)).not.toContain("codex-cli-refresh-token"); }); - it("uses same-account Codex CLI credentials for named Codex profiles after forced local refresh fails", async () => { + it("does not use same-account Codex CLI credentials when default-agent store omits agentDir", async () => { + const profileId = "openai:user@example.com"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai", + access: "local-access-token", + refresh: "local-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-shared", + email: "user@example.com", + }, + }, + }, + agentDir, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai", + access: "codex-cli-access-token", + refresh: "codex-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-shared", + }); + refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => { + throw new Error( + '401 {"error":{"message":"Your refresh token is expired.","code":"refresh_token_expired"}}', + ); + }); + + await expect( + resolveApiKeyForProvider({ + provider: "openai", + store: ensureAuthProfileStore(agentDir), + profileId, + forceRefresh: true, + }), + ).rejects.toThrow(/OAuth token refresh failed for openai/); + + const persisted = await readPersistedStore(agentDir); + const persistedProfile = requireOAuthProfile(persisted, profileId); + expect(persistedProfile.accountId).toBe("acct-shared"); + expect(persistedProfile.access).toBe("local-access-token"); + expect(persistedProfile.refresh).toBe("local-refresh-token"); + expect(JSON.stringify(persisted)).not.toContain("codex-cli-access-token"); + expect(JSON.stringify(persisted)).not.toContain("codex-cli-refresh-token"); + }); + + it("does not use same-account Codex CLI credentials for named Codex profiles after forced local refresh fails", async () => { const profileId = "openai:user@example.com"; saveAuthProfileStore( { @@ -579,11 +659,7 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { agentDir, forceRefresh: true, }), - ).resolves.toEqual({ - apiKey: "codex-cli-access-token", - provider: "openai", - email: "user@example.com", - }); + ).rejects.toThrow(/OAuth token refresh failed for openai/); const persisted = await readPersistedStore(agentDir); const persistedProfile = requireOAuthProfile(persisted, profileId); @@ -593,6 +669,119 @@ describe("resolveApiKeyForProfile openai refresh fallback", () => { expect(JSON.stringify(persisted)).not.toContain("codex-cli-refresh-token"); }); + it("fails closed instead of selecting Codex CLI after an unpinned managed refresh fails", async () => { + const profileId = "openai:user@example.com"; + saveAuthProfileStore( + createExpiredOauthStore({ + profileId, + provider: "openai", + accountId: "acct-shared", + }), + agentDir, + { filterExternalAuthProfiles: false, syncExternalCli: false }, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai", + access: "stale-codex-cli-access-token", + refresh: "stale-codex-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-shared", + }); + refreshProviderOAuthCredentialWithPluginMock.mockRejectedValueOnce( + new Error( + '401 {"error":{"message":"Your refresh token is expired.","code":"refresh_token_expired"}}', + ), + ); + + await expect( + resolveApiKeyForProvider({ + provider: "openai", + agentDir, + }), + ).rejects.toMatchObject({ + name: "OAuthRefreshFailureError", + provider: "openai", + profileId, + }); + }); + + it("does not refresh managed OAuth for direct OpenAI API-key models", async () => { + const profileId = "openai:user@example.com"; + saveAuthProfileStore( + createExpiredOauthStore({ + profileId, + provider: "openai", + accountId: "acct-shared", + }), + agentDir, + { filterExternalAuthProfiles: false, syncExternalCli: false }, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai", + access: "stale-codex-cli-access-token", + refresh: "stale-codex-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-shared", + }); + + await expect( + resolveApiKeyForProvider({ + provider: "openai", + modelApi: "openai-responses", + agentDir, + }), + ).rejects.toThrow('No API key found for provider "openai"'); + expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); + }); + + it("rejects explicit managed OAuth before refreshing for direct OpenAI API-key models", async () => { + const profileId = "openai:user@example.com"; + saveAuthProfileStore( + createExpiredOauthStore({ + profileId, + provider: "openai", + accountId: "acct-shared", + }), + agentDir, + { filterExternalAuthProfiles: false, syncExternalCli: false }, + ); + + await expect( + resolveApiKeyForProvider({ + provider: "openai", + modelApi: "openai-responses", + profileId, + lockedProfile: true, + agentDir, + }), + ).rejects.toThrow(/requires an OpenAI API key profile/); + expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); + }); + + it("does not refresh managed OAuth while checking direct OpenAI auth availability", async () => { + const profileId = "openai:user@example.com"; + saveAuthProfileStore( + createExpiredOauthStore({ + profileId, + provider: "openai", + accountId: "acct-shared", + }), + agentDir, + { filterExternalAuthProfiles: false, syncExternalCli: false }, + ); + + await expect( + hasAvailableAuthForProvider({ + provider: "openai", + modelApi: "openai-responses", + agentDir, + }), + ).resolves.toBe(false); + expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); + }); + it("rejects mismatched Codex CLI fallback after forced local refresh fails", async () => { const profileId = "openai:default"; saveAuthProfileStore( diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 4f3d149c943d..02fa70370f93 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -28,10 +28,7 @@ import { resolveTokenExpiryState, } from "./credential-state.js"; import { formatAuthDoctorHint } from "./doctor.js"; -import { - readExternalCliBootstrapCredential, - readExternalCliFallbackCredential, -} from "./external-cli-sync.js"; +import { readExternalCliBootstrapCredential } from "./external-cli-sync.js"; import { createOAuthManager, OAuthManagerRefreshError } from "./oauth-manager.js"; import { OAuthRefreshFailureError } from "./oauth-refresh-failure.js"; import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js"; @@ -234,19 +231,12 @@ export async function refreshOAuthCredentialForRuntime(params: { const oauthManager = createOAuthManager({ buildApiKey: buildOAuthApiKey, refreshCredential: refreshOAuthCredential, - readBootstrapCredential: ({ profileId, credential }) => + readBootstrapCredential: ({ store, profileId, credential }) => readExternalCliBootstrapCredential({ + store, profileId, credential, }), - readFallbackCredential: ({ profileId, credential }) => - credential.provider === "openai" - ? readExternalCliFallbackCredential({ - profileId, - credential, - allowKeychainPrompt: false, - }) - : null, isRefreshTokenReusedError, }); @@ -521,6 +511,7 @@ export async function resolveApiKeyForProfile( }); throw new OAuthRefreshFailureError({ provider: cred.provider, + profileId, message: `OAuth token refresh failed for ${cred.provider}: ${message}. ` + "Please try again or re-authenticate." + diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 29827352e7ff..b92a87c69685 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -44,6 +44,7 @@ import { resolveAuthProfileOrder, resolveAuthStorePathForDisplay, } from "./auth-profiles.js"; +import { OAuthRefreshFailureError } from "./auth-profiles/oauth-refresh-failure.js"; import * as cliCredentials from "./cli-credentials.js"; import { resolveProviderEnvAuthLookupMaps } from "./model-auth-env-vars.js"; import { @@ -1039,6 +1040,15 @@ export async function resolveApiKeyForProvider(params: { profileId, preferredProfile, }); + const configuredProfileType = store.profiles[profileId]?.type; + if (configuredProfileType) { + assertAuthModeAllowedForModel({ + provider, + modelApi: params.modelApi, + profileId, + mode: profileTypeToAuthMode(configuredProfileType), + }); + } const resolved = await resolveApiKeyForProfile({ cfg, store, @@ -1236,7 +1246,9 @@ export async function resolveApiKeyForProvider(params: { preferredProfile, }); let deferredAuthProfileResult: ResolvedProviderAuth | null = null; + let refreshFailure: OAuthRefreshFailureError | undefined; for (const candidate of order) { + let candidateMode: ResolvedProviderAuth["mode"] | undefined; try { const awsSdkProfileAuth = resolveConfiguredAwsSdkProfileAuth({ cfg, @@ -1246,6 +1258,18 @@ export async function resolveApiKeyForProvider(params: { if (awsSdkProfileAuth) { return awsSdkProfileAuth; } + const candidateType = store.profiles[candidate]?.type; + candidateMode = candidateType ? profileTypeToAuthMode(candidateType) : undefined; + if ( + candidateMode && + !isAuthModeAllowedForModel({ + provider, + modelApi: params.modelApi, + mode: candidateMode, + }) + ) { + continue; + } const resolved = await resolveApiKeyForProfile({ cfg, store, @@ -1288,6 +1312,18 @@ export async function resolveApiKeyForProvider(params: { return result; } } catch (err) { + if ( + !refreshFailure && + err instanceof OAuthRefreshFailureError && + (!candidateMode || + isAuthModeAllowedForModel({ + provider, + modelApi: params.modelApi, + mode: candidateMode, + })) + ) { + refreshFailure = err; + } log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`); } } @@ -1332,6 +1368,10 @@ export async function resolveApiKeyForProvider(params: { return syntheticLocalAuth; } + if (refreshFailure) { + throw refreshFailure; + } + const hasInlineConfiguredModels = Array.isArray(providerConfig?.models) && providerConfig.models.length > 0; const owningPluginIds = !hasInlineConfiguredModels @@ -1493,6 +1533,17 @@ export async function hasAvailableAuthForProvider(params: { if (resolveConfiguredAwsSdkProfileAuth({ cfg, provider, profileId: candidate })) { return true; } + const candidateType = store.profiles[candidate]?.type; + if ( + candidateType && + !isAuthModeAllowedForModel({ + provider, + modelApi: params.modelApi, + mode: profileTypeToAuthMode(candidateType), + }) + ) { + continue; + } const resolved = await resolveApiKeyForProfile({ cfg, store, diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 077cb2b16ee1..b4fee847e59d 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -7111,6 +7111,7 @@ describe("runAgentTurnWithFallback", () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new OAuthRefreshFailureError({ provider: "openai", + profileId: "openai:user@example.com", message: "invalid_grant", }), ); @@ -7121,11 +7122,106 @@ describe("runAgentTurnWithFallback", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai` in a terminal, then try again.", + "⚠️ Model login expired on the gateway for openai. Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth with `openclaw models auth login --provider openai --profile-id 'openai:user@example.com'` in a terminal, then try again.", ); } }); + it("preserves OAuth profile guidance through failover wrappers", async () => { + const refreshError = new OAuthRefreshFailureError({ + provider: "openai", + profileId: "openai:user@example.com", + message: "invalid_grant", + }); + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new FailoverError("OpenAI OAuth failed", { + reason: "auth", + provider: "openai", + model: "gpt-5.5", + profileId: "openai:user@example.com", + authProfileFailure: { allInCooldown: false }, + status: 401, + cause: refreshError, + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'"); + } + }); + + it("preserves OAuth profile guidance through fallback summaries", async () => { + const refreshError = new OAuthRefreshFailureError({ + provider: "openai", + profileId: "openai:user@example.com", + message: "invalid_grant", + }); + const failoverError = new FailoverError("OpenAI OAuth failed", { + reason: "auth", + provider: "openai", + model: "gpt-5.5", + profileId: "openai:user@example.com", + authProfileFailure: { allInCooldown: false }, + status: 401, + cause: refreshError, + }); + const summaryError = new Error("All models failed", { cause: failoverError }); + summaryError.name = "FallbackSummaryError"; + Object.assign(summaryError, { + attempts: [ + { + provider: "openai", + model: "gpt-5.5", + error: "OpenAI OAuth failed", + reason: "auth", + }, + ], + soonestCooldownExpiry: null, + }); + state.runEmbeddedAgentMock.mockRejectedValueOnce(summaryError); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toContain("--profile-id 'openai:user@example.com'"); + } + }); + + it("omits OAuth profile ids from group reauth guidance", async () => { + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new OAuthRefreshFailureError({ + provider: "openai", + profileId: "openai:user@example.com", + message: "invalid_grant", + }), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "whatsapp", + MessageSid: "msg", + ChatType: "group", + } as unknown as TemplateContext, + }), + ); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toContain( + "openclaw models auth login --provider openai` in a terminal", + ); + expect(result.payload.text).not.toContain("user@example.com"); + } + }); + it("keeps non-OpenAI OAuth refresh failures on provider-specific terminal guidance", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new OAuthRefreshFailureError({ @@ -7209,6 +7305,7 @@ describe("runAgentTurnWithFallback", () => { new FailoverError("Auth profile failover exhausted for provider openai", { reason: "auth", provider: "openai", + status: 401, authProfileFailure: { allInCooldown: true }, cause: new Error("invalid_grant"), }), diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 1a023dd0255f..0e4dec9a752e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -23,6 +23,7 @@ import { buildOAuthRefreshFailureLoginCommand, classifyOAuthRefreshFailure, classifyOAuthRefreshFailureError, + formatOAuthRefreshFailureLoginCommandMarkdown, } from "../../agents/auth-profiles/oauth-refresh-failure.js"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import type { BootstrapContextRunKind } from "../../agents/bootstrap-mode.js"; @@ -962,11 +963,41 @@ function supportsChannelCodexLogin(provider: string | null | undefined): boolean function buildExternalRunFailureReply( input: ExternalRunFailureInput, - options?: { includeDetails?: boolean; isHeartbeat?: boolean }, + options?: { + includeAuthProfileId?: boolean; + includeDetails?: boolean; + isHeartbeat?: boolean; + }, ): ExternalRunFailureReply { const message = typeof input === "string" ? input : input.message; const error = typeof input === "string" ? undefined : input.error; const normalizedMessage = collapseRepeatedFailureDetail(message); + const oauthRefreshFailure = + classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage); + if (oauthRefreshFailure) { + const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider, { + profileId: options?.includeAuthProfileId ? oauthRefreshFailure.profileId : undefined, + }); + const loginCommandMarkdown = formatOAuthRefreshFailureLoginCommandMarkdown(loginCommand); + const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""; + const supportsCodexLogin = supportsChannelCodexLogin(oauthRefreshFailure.provider); + const channelLoginHint = supportsCodexLogin + ? "Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth" + : "Re-auth"; + const retryLoginHint = supportsCodexLogin + ? "send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth" + : "re-auth"; + if (oauthRefreshFailure.reason) { + return { + text: `⚠️ Model login expired on the gateway${providerText}. ${channelLoginHint} with ${loginCommandMarkdown} in a terminal, then try again.`, + isGenericRunnerFailure: false, + }; + } + return { + text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, ${retryLoginHint} with ${loginCommandMarkdown} in a terminal.`, + isGenericRunnerFailure: false, + }; + } const authProfileFailoverFailure = buildAuthProfileFailoverFailureText(error); if (authProfileFailoverFailure) { return { text: authProfileFailoverFailure, isGenericRunnerFailure: false }; @@ -985,29 +1016,6 @@ function buildExternalRunFailureReply( if (missingApiKeyFailure) { return { text: missingApiKeyFailure, isGenericRunnerFailure: false }; } - const oauthRefreshFailure = - classifyOAuthRefreshFailureError(error) ?? classifyOAuthRefreshFailure(normalizedMessage); - if (oauthRefreshFailure) { - const loginCommand = buildOAuthRefreshFailureLoginCommand(oauthRefreshFailure.provider); - const providerText = oauthRefreshFailure.provider ? ` for ${oauthRefreshFailure.provider}` : ""; - const supportsCodexLogin = supportsChannelCodexLogin(oauthRefreshFailure.provider); - const channelLoginHint = supportsCodexLogin - ? "Send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth" - : "Re-auth"; - const retryLoginHint = supportsCodexLogin - ? "send `/login codex` from a private chat or Web UI session to pair a new Codex login, or re-auth" - : "re-auth"; - if (oauthRefreshFailure.reason) { - return { - text: `⚠️ Model login expired on the gateway${providerText}. ${channelLoginHint} with \`${loginCommand}\` in a terminal, then try again.`, - isGenericRunnerFailure: false, - }; - } - return { - text: `⚠️ Model login failed on the gateway${providerText}. Please try again. If this keeps happening, ${retryLoginHint} with \`${loginCommand}\` in a terminal.`, - isGenericRunnerFailure: false, - }; - } if (options?.isHeartbeat) { return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, isGenericRunnerFailure: false }; } @@ -1108,6 +1116,7 @@ export function buildKnownAgentRunFailureReplyPayload(params: { const externalRunFailureReply = buildExternalRunFailureReply( { message, error: params.err }, { + includeAuthProfileId: !isNonDirectConversationContext(params.sessionCtx), includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel), }, ); @@ -3204,8 +3213,16 @@ export async function runAgentTurnWithFallback(params: { : isBillingErrorMessage(message); const isContextOverflow = !isBilling && isLikelyContextOverflowError(message); const isCompactionFailure = !isBilling && isCompactionFailureError(message); + const oauthRefreshFailure = + classifyOAuthRefreshFailureError(err) ?? classifyOAuthRefreshFailure(message); + const hasAuthProfileFailoverFailure = buildAuthProfileFailoverFailureText(err) !== null; const providerRequestError = - !isBilling && !shouldSurfaceToControlUi ? classifyProviderRequestError(err) : undefined; + !isBilling && + !oauthRefreshFailure && + !hasAuthProfileFailoverFailure && + !shouldSurfaceToControlUi + ? classifyProviderRequestError(err) + : undefined; const isTransientHttp = isTransientHttpError(message); // Drain/restart aborts stay silent and defer to post-restart @@ -3343,6 +3360,7 @@ export async function runAgentTurnWithFallback(params: { ? buildExternalRunFailureReply( { message, error: err }, { + includeAuthProfileId: !isNonDirectConversationContext(params.sessionCtx), includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel), isHeartbeat: params.isHeartbeat, }, diff --git a/src/commands/doctor-auth.hints.test.ts b/src/commands/doctor-auth.hints.test.ts index e1208198806f..b28599255d83 100644 --- a/src/commands/doctor-auth.hints.test.ts +++ b/src/commands/doctor-auth.hints.test.ts @@ -88,6 +88,19 @@ describe("resolveUnusableProfileHint", () => { ); }); + it("quotes exact current profile ids in OAuth reauth guidance", () => { + expect( + formatOAuthRefreshFailureDoctorLine({ + profileId: "OpenAI Work Profile", + provider: "openai", + message: + "OAuth token refresh failed for openai: invalid_grant. Please try again or re-authenticate.", + }), + ).toBe( + "- OpenAI Work Profile: re-auth required [invalid_grant] — Run `openclaw models auth login --provider openai --profile-id 'OpenAI Work Profile'`.", + ); + }); + it("drops the provider-specific command when the parsed provider is unsafe", () => { expect( formatOAuthRefreshFailureDoctorLine({ diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 305d78f56d2b..4dc5a02ff1fe 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -24,6 +24,7 @@ import { formatAuthDoctorHint } from "../agents/auth-profiles/doctor.js"; import { buildOAuthRefreshFailureLoginCommand, classifyOAuthRefreshFailure, + formatOAuthRefreshFailureLoginCommandMarkdown, type OAuthRefreshFailureReason, } from "../agents/auth-profiles/oauth-refresh-failure.js"; import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/path-resolve.js"; @@ -238,11 +239,14 @@ export function formatOAuthRefreshFailureDoctorLine(params: { const provider = rawProvider ? (DOCTOR_REAUTH_PROVIDER_ALIASES[rawProvider] ?? rawProvider) : null; - const command = buildOAuthRefreshFailureLoginCommand(provider); + const command = buildOAuthRefreshFailureLoginCommand(provider, { + profileId: provider === rawProvider ? params.profileId : undefined, + }); + const commandMarkdown = formatOAuthRefreshFailureLoginCommandMarkdown(command); if (classified.reason) { - return `- ${params.profileId}: re-auth required [${formatOAuthRefreshFailureReason(classified.reason)}] — Run \`${command}\`.`; + return `- ${params.profileId}: re-auth required [${formatOAuthRefreshFailureReason(classified.reason)}] — Run ${commandMarkdown}.`; } - return `- ${params.profileId}: OAuth refresh failed — Try again; if this persists, run \`${command}\`.`; + return `- ${params.profileId}: OAuth refresh failed — Try again; if this persists, run ${commandMarkdown}.`; } async function resolveAuthIssueHint(