diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 42c4eb39f80..87683bb9dba 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -658,6 +658,36 @@ installed and enabled. If you need strict proof while testing, set provider or model `agentRuntime.id: "codex"`. A forced Codex runtime fails instead of falling back to PI. +**OpenAI Codex runtime falls back to the API-key path:** collect a redacted +gateway excerpt that shows the model, runtime, selected provider, and failure. +Ask affected collaborators to run this read-only command on their OpenClaw host: + +```bash +( + pattern='openai/gpt-5\.[45]|agentRuntime(\.id)?|harnessRuntime|Runtime: OpenAI Codex|openai-codex|resolveSelectedOpenAIPiRuntimeProvider|candidateProvider[": ]+openai|status[": ]+401|Incorrect API key|No API key|api-key path|API-key path|OAuth' + + if ls /tmp/openclaw/openclaw-*.log >/dev/null 2>&1; then + grep -E -i -n "$pattern" /tmp/openclaw/openclaw-*.log 2>/dev/null || true + else + journalctl --user -u openclaw-gateway --since today --no-pager 2>/dev/null \ + | grep -E -i "$pattern" || true + fi +) | sed -E \ + -e 's/(Authorization: Bearer )[A-Za-z0-9._~+\/-]+/\1[REDACTED]/Ig' \ + -e 's/(Bearer )[A-Za-z0-9._~+\/-]+/\1[REDACTED]/Ig' \ + -e 's/(api[_ -]?key[=: ]+)[^ ,}"]+/\1[REDACTED]/Ig' \ + -e 's/(OPENAI_API_KEY[=: ]+)[^ ,}"]+/\1[REDACTED]/Ig' \ + -e 's/sk-[A-Za-z0-9_-]{12,}/sk-[REDACTED]/g' \ + -e 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/[EMAIL-REDACTED]/g' \ + | tail -200 +``` + +Useful excerpts usually include `openai/gpt-5.5` or `openai/gpt-5.4`, +`Runtime: OpenAI Codex`, `agentRuntime.id` or `harnessRuntime`, +`candidateProvider: "openai"`, and a `401`, `Incorrect API key`, or +`No API key` result. A corrected run should show the `openai-codex` OAuth +path instead of a plain OpenAI API-key failure. + **Legacy `openai-codex/*` config remains:** run `openclaw doctor --fix`. Doctor rewrites legacy model refs to `openai/*`, removes stale session and whole-agent runtime pins, and preserves existing auth-profile overrides. diff --git a/src/agents/openai-codex-routing.test.ts b/src/agents/openai-codex-routing.test.ts index aabb02508d7..9b422aa4ab4 100644 --- a/src/agents/openai-codex-routing.test.ts +++ b/src/agents/openai-codex-routing.test.ts @@ -150,4 +150,22 @@ describe("OpenAI Codex routing policy", () => { }), ).toEqual(["openai-codex"]); }); + + it("routes openai provider to openai-codex when harness runtime is codex", () => { + expect( + resolveSelectedOpenAIPiRuntimeProvider({ + provider: "openai", + harnessRuntime: "codex", + }), + ).toBe("openai-codex"); + }); + + it("does not route non-OpenAI providers when runtime is codex", () => { + expect( + resolveSelectedOpenAIPiRuntimeProvider({ + provider: "anthropic", + harnessRuntime: "codex", + }), + ).toBe("anthropic"); + }); }); diff --git a/src/agents/openai-codex-routing.ts b/src/agents/openai-codex-routing.ts index ea0decb8511..4133f40505a 100644 --- a/src/agents/openai-codex-routing.ts +++ b/src/agents/openai-codex-routing.ts @@ -181,8 +181,13 @@ export function resolveSelectedOpenAIPiRuntimeProvider(params: { return OPENAI_CODEX_PROVIDER_ID; } const runtime = normalizeEmbeddedAgentRuntime(params.agentHarnessId ?? params.harnessRuntime); - return isOpenAIProvider(params.provider) && - runtime === "pi" && + if (!isOpenAIProvider(params.provider)) { + return params.provider; + } + if (runtime === "codex") { + return OPENAI_CODEX_PROVIDER_ID; + } + return runtime === "pi" && !params.authProfileId?.trim() && configuredOpenAIAuthOrderStartsWithCodexProfile(params.config) ? OPENAI_CODEX_PROVIDER_ID diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 49964bb86d6..0d5815f2c3c 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -27,6 +27,7 @@ import { mockedResolveAuthProfileOrder, mockedResolveContextWindowInfo, mockedResolveFailoverStatus, + mockedResolveModelAsync, mockedRunContextEngineMaintenance, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, @@ -496,7 +497,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1); expect(pluginRunAttempt).toHaveBeenCalledTimes(1); const pluginParams = expectMockCallFields(pluginRunAttempt, { - provider: "openai", + provider: "openai-codex", authProfileId: "openai-codex:work", authProfileIdSource: "user", }); @@ -522,6 +523,272 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(successParams.profileId).toBe("openai-codex:work"); }); + it("bootstraps OAuth credentials for forced openai/* Codex response runs", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const pluginRunAttempt = vi.fn(async () => + makeAttemptResult({ assistantTexts: ["ok"] }), + ); + const codexAuthStorage = { + setRuntimeApiKey: vi.fn(), + getApiKey: vi.fn(async () => "stored-test-key"), + }; + const runtimePlan = makeForwardedRuntimePlan({ + resolvedRef: { + provider: "openai-codex", + modelId: "gpt-5.5", + harnessId: "codex", + }, + auth: { + providerForAuth: "openai-codex", + authProfileProviderForAuth: "openai-codex", + harnessAuthProvider: "openai-codex", + forwardedAuthProfileId: "openai-codex:work", + }, + }); + const codexAuthStore = { + version: 1 as const, + profiles: { + "openai-codex:work": { + type: "oauth" as const, + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: false }), + runAttempt: pluginRunAttempt, + }); + mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); + mockedResolveModelAsync + .mockResolvedValueOnce({ + model: { + id: "gpt-5.5", + provider: "openai", + contextWindow: 200000, + api: "openai-responses", + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }) + .mockResolvedValueOnce({ + model: { + id: "gpt-5.5", + provider: "openai-codex", + contextWindow: 200000, + api: "openai-codex-responses", + }, + error: null, + authStorage: codexAuthStorage, + modelRegistry: {}, + }); + mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); + + try { + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + }, + authProfileId: "openai-codex:work", + authProfileIdSource: "user", + runId: "forced-openai-codex-responses-bootstrap-oauth", + }); + } finally { + clearAgentHarnesses(); + } + + expect(mockedGetApiKeyForModel).toHaveBeenCalledTimes(1); + expectMockCallFields(mockedGetApiKeyForModel, { + profileId: "openai-codex:work", + }); + expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenCalledWith("openai-codex", "test-key"); + expect(pluginRunAttempt).toHaveBeenCalledTimes(1); + expectMockCallFields(pluginRunAttempt, { + provider: "openai-codex", + authProfileId: "openai-codex:work", + authProfileIdSource: "user", + resolvedApiKey: "test-key", + }); + }); + + it("refreshes bootstrapped Codex OAuth credentials when rotating profiles", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const subscriptionLimit = new Error( + "You've reached your Codex subscription usage limit. Next reset in 20 hours.", + ); + const normalizedLimit = Object.assign(new Error(subscriptionLimit.message), { + name: "FailoverError", + reason: "rate_limit", + status: 429, + }); + let attemptCount = 0; + const pluginRunAttempt = vi.fn(async () => { + attemptCount += 1; + return attemptCount === 1 + ? makeAttemptResult({ promptError: subscriptionLimit }) + : makeAttemptResult({ assistantTexts: ["backup ok"], promptError: null }); + }); + const codexAuthStorage = { + setRuntimeApiKey: vi.fn(), + getApiKey: vi.fn(async () => "stored-test-key"), + }; + const firstRuntimePlan = makeForwardedRuntimePlan({ + resolvedRef: { + provider: "openai-codex", + modelId: "gpt-5.5", + harnessId: "codex", + }, + auth: { + providerForAuth: "openai-codex", + authProfileProviderForAuth: "openai-codex", + harnessAuthProvider: "openai-codex", + forwardedAuthProfileId: "openai-codex:sub", + forwardedAuthProfileCandidateIds: ["openai-codex:sub", "openai-codex:backup"], + }, + }); + const secondRuntimePlan = makeForwardedRuntimePlan({ + resolvedRef: { + provider: "openai-codex", + modelId: "gpt-5.5", + harnessId: "codex", + }, + auth: { + providerForAuth: "openai-codex", + authProfileProviderForAuth: "openai-codex", + harnessAuthProvider: "openai-codex", + forwardedAuthProfileId: "openai-codex:backup", + forwardedAuthProfileCandidateIds: ["openai-codex:sub", "openai-codex:backup"], + }, + }); + const codexAuthStore = { + version: 1 as const, + profiles: { + "openai-codex:sub": { + type: "oauth" as const, + provider: "openai-codex", + access: "sub-access-token", + refresh: "sub-refresh-token", + expires: Date.now() + 60_000, + }, + "openai-codex:backup": { + type: "oauth" as const, + provider: "openai-codex", + access: "backup-access-token", + refresh: "backup-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: false }), + runAttempt: pluginRunAttempt, + }); + mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); + mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai-codex:sub", "openai-codex:backup"]); + mockedResolveModelAsync + .mockResolvedValueOnce({ + model: { + id: "gpt-5.5", + provider: "openai", + contextWindow: 200000, + api: "openai-responses", + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }) + .mockResolvedValueOnce({ + model: { + id: "gpt-5.5", + provider: "openai-codex", + contextWindow: 200000, + api: "openai-codex-responses", + }, + error: null, + authStorage: codexAuthStorage, + modelRegistry: {}, + }); + mockedBuildAgentRuntimePlan + .mockReturnValueOnce(firstRuntimePlan) + .mockReturnValueOnce(secondRuntimePlan); + mockedGetApiKeyForModel.mockImplementation( + async ({ profileId }: { profileId?: string } = {}) => ({ + apiKey: profileId === "openai-codex:backup" ? "backup-token" : "sub-token", + profileId: profileId ?? "openai-codex:sub", + source: "test", + mode: "api-key", + }), + ); + mockedCoerceToFailoverError.mockReturnValueOnce(normalizedLimit); + mockedDescribeFailoverError.mockImplementation((err: unknown) => ({ + message: err instanceof Error ? err.message : String(err), + reason: err === normalizedLimit ? "rate_limit" : undefined, + status: err === normalizedLimit ? 429 : undefined, + code: undefined, + })); + + try { + await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + }, + runId: "forced-openai-codex-responses-rotates-oauth", + }); + } finally { + clearAgentHarnesses(); + } + + expect(mockedGetApiKeyForModel).toHaveBeenCalledTimes(2); + expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenNthCalledWith( + 1, + "openai-codex", + "sub-token", + ); + expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenNthCalledWith( + 2, + "openai-codex", + "backup-token", + ); + expect(pluginRunAttempt).toHaveBeenCalledTimes(2); + expectMockCallFields(pluginRunAttempt, { + provider: "openai-codex", + authProfileId: "openai-codex:sub", + resolvedApiKey: "sub-token", + }); + expectMockCallFields( + pluginRunAttempt, + { + provider: "openai-codex", + authProfileId: "openai-codex:backup", + resolvedApiKey: "backup-token", + }, + 1, + ); + }); + it("keeps auto-selected OpenAI Codex auth profiles for forced codex harness runs", async () => { const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); const pluginRunAttempt = vi.fn(async () => @@ -573,7 +840,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1); expect(pluginRunAttempt).toHaveBeenCalledTimes(1); const pluginParams = expectMockCallFields(pluginRunAttempt, { - provider: "openai", + provider: "openai-codex", authProfileId: "openai-codex:default", authProfileIdSource: "auto", }); @@ -646,7 +913,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1); expect(pluginRunAttempt).toHaveBeenCalledTimes(1); const pluginParams = expectMockCallFields(pluginRunAttempt, { - provider: "openai", + provider: "openai-codex", authProfileId: "openai-codex:default", authProfileIdSource: "auto", }); @@ -731,7 +998,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1); expect(pluginRunAttempt).toHaveBeenCalledTimes(1); const pluginParams = expectMockCallFields(pluginRunAttempt, { - provider: "openai", + provider: "openai-codex", authProfileId: "openai:personal", authProfileIdSource: "auto", }); @@ -863,14 +1130,14 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); expect(pluginRunAttempt).toHaveBeenCalledTimes(2); const firstAttempt = expectMockCallFields(pluginRunAttempt, { - provider: "openai", + provider: "openai-codex", authProfileId: "openai-codex:sub", authProfileIdSource: "auto", }); const secondAttempt = expectMockCallFields( pluginRunAttempt, { - provider: "openai", + provider: "openai-codex", authProfileId: "openai:backup", authProfileIdSource: "auto", }, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index b51327ccf8e..be387e03b28 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -62,6 +62,7 @@ import { } from "../model-auth.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { + OPENAI_CODEX_PROVIDER_ID, listOpenAIAuthProfileProvidersForAgentRuntime, resolveContextConfigProviderForRuntime, resolveSelectedOpenAIPiRuntimeProvider, @@ -685,16 +686,22 @@ export async function runEmbeddedPiAgent( startupStages.mark("model-resolution"); notifyExecutionPhase("model_resolution", { provider, model: modelId }); - const authStore = pluginHarnessOwnsTransport - ? createEmptyAuthProfileStore() - : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { - allowKeychainPrompt: false, - }); - const attemptAuthProfileStore = pluginHarnessOwnsTransport - ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { - allowKeychainPrompt: false, - }) - : authStore; + const pluginHarnessNeedsOpenClawAuthBootstrap = + pluginHarnessOwnsTransport && + provider === OPENAI_CODEX_PROVIDER_ID && + effectiveModel.api === "openai-codex-responses"; + const authStore = + pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap + ? createEmptyAuthProfileStore() + : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }); + const attemptAuthProfileStore = + pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap + ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }) + : authStore; const requestedProfileId = params.authProfileId?.trim(); const requestedProfileIsUserLocked = params.authProfileIdSource === "user"; const isForwardablePluginHarnessAuthProfile = ( @@ -943,11 +950,15 @@ export async function runEmbeddedPiAgent( } return false; }; + const advanceAttemptAuthProfile = + pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap + ? advancePluginHarnessAuthProfile + : advanceAuthProfile; // Plugin harnesses own their model transport/auth. Running PI's generic // auth bootstrap here can turn synthetic provider markers into real // vendor-token refresh attempts before the plugin gets control. - if (!pluginHarnessOwnsTransport) { + if (!pluginHarnessOwnsTransport || pluginHarnessNeedsOpenClawAuthBootstrap) { await initializeAuthProfile(); } else if (lockedProfileId) { lastProfileId = lockedProfileId; @@ -2277,9 +2288,7 @@ export async function runEmbeddedPiAgent( }); if ( promptFailoverDecision.action === "rotate_profile" && - (await (pluginHarnessOwnsTransport - ? advancePluginHarnessAuthProfile() - : advanceAuthProfile())) + (await advanceAttemptAuthProfile()) ) { if (failedPromptProfileId && promptProfileFailureReason) { void maybeMarkAuthProfileFailure({ @@ -2509,9 +2518,7 @@ export async function runEmbeddedPiAgent( maybeMarkAuthProfileFailure, maybeEscalateRateLimitProfileFallback, maybeBackoffBeforeOverloadFailover, - advanceAuthProfile: pluginHarnessOwnsTransport - ? advancePluginHarnessAuthProfile - : advanceAuthProfile, + advanceAuthProfile: advanceAttemptAuthProfile, }); overloadProfileRotations = assistantFailoverOutcome.overloadProfileRotations; if (assistantFailoverOutcome.action === "retry") {