fix(auth): harden openai-codex oauth refresh fallback

This commit is contained in:
Vignesh Natarajan
2026-03-05 19:17:50 -08:00
parent 71ec42127d
commit fa3fafdde5
3 changed files with 183 additions and 1 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Kimi Coding/Anthropic tools compatibility: normalize `anthropic-messages` tool payloads to OpenAI-style `tools[].function` + compatible `tool_choice` when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
- Heartbeat/workspace-path guardrails: append explicit workspace `HEARTBEAT.md` path guidance (and `docs/heartbeat.md` avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
- Subagents/kill-complete announce race: when a late `subagent-complete` lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
- Cron/OpenAI Codex OAuth refresh hardening: when `openai-codex` token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal.
- Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.
- Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao.
- Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.

View File

@@ -0,0 +1,141 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import { resolveApiKeyForProfile } from "./oauth.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStore,
saveAuthProfileStore,
} from "./store.js";
import type { AuthProfileStore } from "./types.js";
const { getOAuthApiKeyMock } = vi.hoisted(() => ({
getOAuthApiKeyMock: vi.fn(async () => {
throw new Error("Failed to extract accountId from token");
}),
}));
vi.mock("@mariozechner/pi-ai", async () => {
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
return {
...actual,
getOAuthApiKey: getOAuthApiKeyMock,
getOAuthProviders: () => [
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" },
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" },
],
};
});
function createExpiredOauthStore(params: {
profileId: string;
provider: string;
access?: string;
}): AuthProfileStore {
return {
version: 1,
profiles: {
[params.profileId]: {
type: "oauth",
provider: params.provider,
access: params.access ?? "cached-access-token",
refresh: "refresh-token",
expires: Date.now() - 60_000,
},
},
};
}
describe("resolveApiKeyForProfile openai-codex refresh fallback", () => {
const envSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_AGENT_DIR",
"PI_CODING_AGENT_DIR",
]);
let tempRoot = "";
let agentDir = "";
beforeEach(async () => {
getOAuthApiKeyMock.mockClear();
clearRuntimeAuthProfileStoreSnapshots();
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-refresh-fallback-"));
agentDir = path.join(tempRoot, "agents", "main", "agent");
await fs.mkdir(agentDir, { recursive: true });
process.env.OPENCLAW_STATE_DIR = tempRoot;
process.env.OPENCLAW_AGENT_DIR = agentDir;
process.env.PI_CODING_AGENT_DIR = agentDir;
});
afterEach(async () => {
clearRuntimeAuthProfileStoreSnapshots();
envSnapshot.restore();
await fs.rm(tempRoot, { recursive: true, force: true });
});
it("falls back to cached access token when openai-codex refresh fails on accountId extraction", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai-codex",
}),
agentDir,
);
const result = await resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
});
expect(result).toEqual({
apiKey: "cached-access-token",
provider: "openai-codex",
email: undefined,
});
expect(getOAuthApiKeyMock).toHaveBeenCalledTimes(1);
});
it("keeps throwing for non-codex providers on the same refresh error", async () => {
const profileId = "anthropic:default";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "anthropic",
}),
agentDir,
);
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}),
).rejects.toThrow(/OAuth token refresh failed for anthropic/);
});
it("does not use fallback for unrelated openai-codex refresh errors", async () => {
const profileId = "openai-codex:default";
saveAuthProfileStore(
createExpiredOauthStore({
profileId,
provider: "openai-codex",
}),
agentDir,
);
getOAuthApiKeyMock.mockImplementationOnce(async () => {
throw new Error("invalid_grant");
});
await expect(
resolveApiKeyForProfile({
store: ensureAuthProfileStore(agentDir),
profileId,
agentDir,
}),
).rejects.toThrow(/OAuth token refresh failed for openai-codex/);
});
});

View File

@@ -10,6 +10,7 @@ import { withFileLock } from "../../infra/file-lock.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { normalizeProviderId } from "../model-selection.js";
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import { resolveTokenExpiryState } from "./credential-state.js";
import { formatAuthDoctorHint } from "./doctor.js";
@@ -87,6 +88,27 @@ function buildOAuthProfileResult(params: {
});
}
function extractErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function shouldUseOpenaiCodexRefreshFallback(params: {
provider: string;
credentials: OAuthCredentials;
error: unknown;
}): boolean {
if (normalizeProviderId(params.provider) !== "openai-codex") {
return false;
}
const message = extractErrorMessage(params.error);
if (!/extract\s+accountid\s+from\s+token/i.test(message)) {
return false;
}
return (
typeof params.credentials.access === "string" && params.credentials.access.trim().length > 0
);
}
type ResolveApiKeyForProfileParams = {
cfg?: OpenClawConfig;
store: AuthProfileStore;
@@ -434,7 +456,25 @@ export async function resolveApiKeyForProfile(
}
}
const message = error instanceof Error ? error.message : String(error);
if (
shouldUseOpenaiCodexRefreshFallback({
provider: cred.provider,
credentials: cred,
error,
})
) {
log.warn("openai-codex oauth refresh failed; using cached access token fallback", {
profileId,
provider: cred.provider,
});
return buildApiKeyProfileResult({
apiKey: cred.access,
provider: cred.provider,
email: cred.email,
});
}
const message = extractErrorMessage(error);
const hint = formatAuthDoctorHint({
cfg,
store: refreshedStore,