[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
This commit is contained in:
Peter Steinberger
2026-05-15 12:32:00 +01:00
committed by GitHub
parent b6809b5e31
commit b3d9bef38d
7 changed files with 310 additions and 8 deletions

View File

@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Agents/replies: strip workflow `<function_response>` 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.

View File

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

View File

@@ -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<string> | undefined): Set<string> | undefined {
if (values === undefined) {
return undefined;

View File

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

View File

@@ -78,7 +78,7 @@ export function hasOAuthIdentity(
);
}
function hasMatchingOAuthIdentity(
export function hasMatchingOAuthIdentity(
existing: Pick<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
): boolean {

View File

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

View File

@@ -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,
});