mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:14:46 +00:00
[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:
committed by
GitHub
parent
b6809b5e31
commit
b3d9bef38d
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,7 +78,7 @@ export function hasOAuthIdentity(
|
||||
);
|
||||
}
|
||||
|
||||
function hasMatchingOAuthIdentity(
|
||||
export function hasMatchingOAuthIdentity(
|
||||
existing: Pick<OAuthCredential, "accountId" | "email">,
|
||||
incoming: Pick<OAuthCredential, "accountId" | "email">,
|
||||
): boolean {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user