diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index dbbcb98be95..023eb85544b 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -393,10 +393,9 @@ function resolveCodexAppServerModelProvider(params: { isCodexAppServerNativeAuthProfile(params) && (normalizedLower === "openai" || normalizedLower === "openai-codex") ) { - // When OpenClaw is forwarding ChatGPT/Codex OAuth, forcing the public - // OpenAI model provider makes app-server call api.openai.com without the - // ChatGPT bearer and fails with "Missing bearer or basic authentication". - // Omit the provider so app-server keeps its native account-backed route. + // When OpenClaw is forwarding ChatGPT/Codex OAuth, `openai` is Codex's + // native provider id, not a public OpenAI API-key choice. Omit the override + // so app-server keeps its configured provider/auth pair for this session. return undefined; } return normalizedLower === "openai-codex" ? "openai" : normalized; diff --git a/extensions/codex/src/conversation-control.test.ts b/extensions/codex/src/conversation-control.test.ts index a4a0c883621..0b33fefa35f 100644 --- a/extensions/codex/src/conversation-control.test.ts +++ b/extensions/codex/src/conversation-control.test.ts @@ -1,24 +1,37 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime"; +import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readCodexAppServerBinding, writeCodexAppServerBinding, } from "./app-server/session-binding.js"; import { setCodexConversationFastMode, + setCodexConversationModel, setCodexConversationPermissions, } from "./conversation-control.js"; let tempDir: string; +const sharedClientMocks = vi.hoisted(() => ({ + getSharedCodexAppServerClient: vi.fn(), +})); + +vi.mock("./app-server/shared-client.js", () => sharedClientMocks); + describe("codex conversation controls", () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-control-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + sharedClientMocks.getSharedCodexAppServerClient.mockReset(); }); afterEach(async () => { + vi.unstubAllEnvs(); + clearRuntimeAuthProfileStoreSnapshots(); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -47,4 +60,46 @@ describe("codex conversation controls", () => { sandbox: "workspace-write", }); }); + + it("does not persist public OpenAI provider after model changes on native auth bindings", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + upsertAuthProfile({ + profileId: "work", + credential: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-1", + cwd: tempDir, + authProfileId: "work", + model: "gpt-5.4", + modelProvider: "openai", + }); + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async () => ({ + thread: { id: "thread-1", cwd: tempDir }, + model: "gpt-5.5", + modelProvider: "openai", + })), + }); + + await expect(setCodexConversationModel({ sessionFile, model: "gpt-5.5" })).resolves.toBe( + "Codex model set to gpt-5.5.", + ); + + const raw = await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"); + const binding = await readCodexAppServerBinding(sessionFile); + expect(raw).not.toContain('"modelProvider": "openai"'); + expect(binding).toMatchObject({ + threadId: "thread-1", + authProfileId: "work", + model: "gpt-5.5", + }); + expect(binding?.modelProvider).toBeUndefined(); + }); });