From b3d9bef38d5db9bf8e90635209ff41028ea3a634 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 15 May 2026 12:32:00 +0100 Subject: [PATCH] [codex] Fix Codex OAuth refresh fallback (#82117) * fix: fall back to Codex CLI OAuth after refresh failure * fix: support Codex CLI fallback for named profiles --- CHANGELOG.md | 1 + docs/concepts/oauth.md | 10 +- src/agents/auth-profiles/external-cli-sync.ts | 24 +- src/agents/auth-profiles/oauth-manager.ts | 33 +++ src/agents/auth-profiles/oauth-shared.ts | 2 +- ...auth.openai-codex-refresh-fallback.test.ts | 235 +++++++++++++++++- src/agents/auth-profiles/oauth.ts | 13 +- 7 files changed, 310 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c14780ef5..b4674f652b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai - Agents/replies: strip workflow `` scaffolding from user-visible sanitizer paths so raw tool output does not leak into chat history, transcript mirrors, or channel replies. Fixes #47444. Thanks @5toCode. - Agents/media: deliver generated image, music, and video results through structured attachments, keep message-tool-only Codex completions on the message tool, and fail completion handoff when expected media is not actually sent. - Diagnostics/Codex: recover stalled embedded Codex app-server runs after the shorter default stalled-run window so queued turns resume sooner. +- Codex app-server: fall back to same-account Codex CLI OAuth tokens at runtime when the local OpenAI Codex refresh token is rejected, without overwriting the canonical OpenClaw auth profile. Fixes #82069. Thanks @aaajiao. - Control UI: rotate browser service-worker caches per build so updated Gateways are less likely to keep serving stale dashboard bundles that trigger protocol mismatch errors. - Gateway/protocol: lazy-compile protocol validators on first use instead of compiling every AJV schema during cold import, reducing startup CPU and RSS. (#82064) Thanks @samzong. - Discord: report unresolved configured bot-token SecretRefs during startup instead of treating the account as unconfigured. (#82009) Thanks @giodl73-repo. diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index d4a63d651ee..9a219eca85e 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -46,8 +46,10 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**: - we can keep multiple profiles and route them deterministically - external CLI reuse is provider-specific: Codex CLI can bootstrap an empty `openai-codex:default` profile, but once OpenClaw has a local OAuth profile, - the local refresh token is canonical; other integrations can remain - externally managed and re-read their CLI auth store + 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 - 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 @@ -146,7 +148,9 @@ At runtime: re-reads those CLI auth stores instead of spending copied refresh tokens. Codex CLI bootstrap is intentionally narrower: it seeds an empty `openai-codex:default` profile, then OpenClaw-owned refreshes keep the local - profile canonical. + 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`. The refresh flow is automatic; you generally don't need to manage tokens manually. diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index b8539bda6f0..da5db3441b0 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -154,19 +154,39 @@ function hasInlineOAuthTokenMaterial(credential: OAuthCredential): boolean { export function readExternalCliBootstrapCredential(params: { profileId: string; credential: OAuthCredential; + allowInlineOAuthTokenMaterial?: boolean; + allowKeychainPrompt?: boolean; }): OAuthCredential | null { const provider = resolveExternalCliSyncProvider(params); if (!provider) { return null; } - if (provider.bootstrapOnly && hasInlineOAuthTokenMaterial(params.credential)) { + if ( + provider.bootstrapOnly && + !params.allowInlineOAuthTokenMaterial && + hasInlineOAuthTokenMaterial(params.credential) + ) { return null; } - return provider.readCredentials(); + return provider.readCredentials({ allowKeychainPrompt: params.allowKeychainPrompt }); } export const readManagedExternalCliCredential = readExternalCliBootstrapCredential; +export function readExternalCliFallbackCredential(params: { + profileId: string; + credential: OAuthCredential; + allowKeychainPrompt?: boolean; +}): OAuthCredential | null { + const provider = + resolveExternalCliSyncProvider(params) ?? + EXTERNAL_CLI_SYNC_PROVIDERS.find((entry) => entry.provider === params.credential.provider); + if (!provider) { + return null; + } + return provider.readCredentials({ allowKeychainPrompt: params.allowKeychainPrompt }); +} + function normalizeProviderScope(values: Iterable | undefined): Set | undefined { if (values === undefined) { return undefined; diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index d487ca8f9ca..26aaf9c75a2 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -14,6 +14,7 @@ import { } from "./oauth-refresh-lock-errors.js"; import { areOAuthCredentialsEquivalent, + hasMatchingOAuthIdentity, hasUsableOAuthCredential, isSafeToAdoptBootstrapOAuthIdentity, isSafeToAdoptMainStoreOAuthIdentity, @@ -45,6 +46,10 @@ export type OAuthManagerAdapter = { profileId: string; credential: OAuthCredential; }) => OAuthCredential | null; + readFallbackCredential?: (params: { + profileId: string; + credential: OAuthCredential; + }) => OAuthCredential | null; isRefreshTokenReusedError: (error: unknown) => boolean; }; @@ -648,6 +653,34 @@ 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, profileId: params.profileId, diff --git a/src/agents/auth-profiles/oauth-shared.ts b/src/agents/auth-profiles/oauth-shared.ts index 9b09ec73dee..dda697ec958 100644 --- a/src/agents/auth-profiles/oauth-shared.ts +++ b/src/agents/auth-profiles/oauth-shared.ts @@ -78,7 +78,7 @@ export function hasOAuthIdentity( ); } -function hasMatchingOAuthIdentity( +export function hasMatchingOAuthIdentity( existing: Pick, incoming: Pick, ): boolean { 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 14fe78a7ddf..eaef3f0773c 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 @@ -21,7 +21,9 @@ const { getOAuthApiKeyMock } = vi.hoisted(() => ({ })); const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ - readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), + readCodexCliCredentialsCachedMock: vi.fn<(_options?: unknown) => OAuthCredential | null>( + () => null, + ), })); const { @@ -462,6 +464,237 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { expect(persisted.profiles[profileId]).not.toHaveProperty("refresh"); }); + it("uses same-account Codex CLI credentials after forced local refresh fails", async () => { + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "local-access-token", + refresh: "local-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-shared", + }, + }, + }, + agentDir, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai-codex", + 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( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + forceRefresh: true, + }), + ).resolves.toEqual({ + apiKey: "codex-cli-access-token", + provider: "openai-codex", + email: undefined, + }); + + 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"); + expect(persistedProfile).not.toHaveProperty("access"); + expect(persistedProfile).not.toHaveProperty("refresh"); + expect(JSON.stringify(persisted)).not.toContain("codex-cli-access-token"); + 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 () => { + const profileId = "openai-codex:user@example.com"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + 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-codex", + 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( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + forceRefresh: true, + }), + ).resolves.toEqual({ + apiKey: "codex-cli-access-token", + provider: "openai-codex", + email: "user@example.com", + }); + + const persisted = await readPersistedStore(agentDir); + const persistedProfile = requireOAuthProfile(persisted, profileId); + expect(persistedProfile.accountId).toBe("acct-shared"); + expect(persistedProfile.email).toBe("user@example.com"); + expect(JSON.stringify(persisted)).not.toContain("codex-cli-access-token"); + expect(JSON.stringify(persisted)).not.toContain("codex-cli-refresh-token"); + }); + + it("rejects mismatched Codex CLI fallback after forced local refresh fails", async () => { + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "local-access-token", + refresh: "local-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-local", + }, + }, + }, + agentDir, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai-codex", + access: "codex-cli-access-token", + refresh: "codex-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-other", + }); + refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => { + throw new Error( + '401 {"error":{"message":"Your refresh token is expired.","code":"refresh_token_expired"}}', + ); + }); + + await expect( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + forceRefresh: true, + }), + ).rejects.toThrow(/OAuth token refresh failed for openai-codex/); + }); + + it("rejects identity-less Codex CLI fallback after forced local refresh fails", async () => { + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "local-access-token", + refresh: "local-refresh-token", + expires: Date.now() + 86_400_000, + }, + }, + }, + agentDir, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai-codex", + access: "codex-cli-access-token", + refresh: "codex-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-cli", + }); + refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => { + throw new Error( + '401 {"error":{"message":"Your refresh token is expired.","code":"refresh_token_expired"}}', + ); + }); + + await expect( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + forceRefresh: true, + }), + ).rejects.toThrow(/OAuth token refresh failed for openai-codex/); + }); + + it("rejects unchanged Codex CLI fallback during forced refresh", async () => { + const profileId = "openai-codex:default"; + const credential: OAuthCredential = { + type: "oauth", + provider: "openai-codex", + access: "shared-access-token", + refresh: "shared-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-shared", + }; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: credential, + }, + }, + agentDir, + ); + readCodexCliCredentialsCachedMock.mockReturnValue({ ...credential }); + refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce(async () => { + throw new Error( + '401 {"error":{"message":"Your refresh token is expired.","code":"refresh_token_expired"}}', + ); + }); + + await expect( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + forceRefresh: true, + }), + ).rejects.toThrow(/OAuth token refresh failed for openai-codex/); + }); + it("adopts fresher stored credentials after refresh_token_reused", async () => { const profileId = "openai-codex:default"; saveAuthProfileStore( diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 696a8a273c0..17ad3aecb07 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -19,7 +19,10 @@ import { refreshChutesTokens } from "../chutes-oauth.js"; import { log } from "./constants.js"; import { resolveTokenExpiryState } from "./credential-state.js"; import { formatAuthDoctorHint } from "./doctor.js"; -import { readManagedExternalCliCredential } from "./external-cli-sync.js"; +import { + readExternalCliFallbackCredential, + readManagedExternalCliCredential, +} from "./external-cli-sync.js"; import { createOAuthManager, OAuthManagerRefreshError } from "./oauth-manager.js"; import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; @@ -184,6 +187,14 @@ const oauthManager = createOAuthManager({ profileId, credential, }), + readFallbackCredential: ({ profileId, credential }) => + credential.provider === "openai-codex" + ? readExternalCliFallbackCredential({ + profileId, + credential, + allowKeychainPrompt: false, + }) + : null, isRefreshTokenReusedError, });