diff --git a/extensions/codex/src/app-server/session-binding.test.ts b/extensions/codex/src/app-server/session-binding.test.ts index 49c7abe2874..38ee27384c2 100644 --- a/extensions/codex/src/app-server/session-binding.test.ts +++ b/extensions/codex/src/app-server/session-binding.test.ts @@ -44,6 +44,51 @@ describe("codex app-server session binding", () => { await expect(fs.stat(resolveCodexAppServerBindingPath(sessionFile))).resolves.toBeTruthy(); }); + it("does not persist public OpenAI as the provider for Codex-native auth bindings", async () => { + const sessionFile = path.join(tempDir, "session.json"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-123", + cwd: tempDir, + authProfileId: "openai-codex:work", + model: "gpt-5.4-mini", + modelProvider: "openai", + }); + + const raw = await fs.readFile(resolveCodexAppServerBindingPath(sessionFile), "utf8"); + const binding = await readCodexAppServerBinding(sessionFile); + + expect(raw).not.toContain('"modelProvider": "openai"'); + expect(binding).toMatchObject({ + threadId: "thread-123", + authProfileId: "openai-codex:work", + model: "gpt-5.4-mini", + }); + expect(binding?.modelProvider).toBeUndefined(); + }); + + it("normalizes older Codex-native bindings that stored public OpenAI provider", async () => { + const sessionFile = path.join(tempDir, "session.json"); + await fs.writeFile( + resolveCodexAppServerBindingPath(sessionFile), + `${JSON.stringify({ + schemaVersion: 1, + threadId: "thread-123", + sessionFile, + cwd: tempDir, + authProfileId: "openai-codex:work", + model: "gpt-5.4-mini", + modelProvider: "openai", + createdAt: "2026-05-03T00:00:00.000Z", + updatedAt: "2026-05-03T00:00:00.000Z", + })}\n`, + ); + + const binding = await readCodexAppServerBinding(sessionFile); + + expect(binding?.authProfileId).toBe("openai-codex:work"); + expect(binding?.modelProvider).toBeUndefined(); + }); + it("clears missing bindings without throwing", async () => { const sessionFile = path.join(tempDir, "missing.json"); await clearCodexAppServerBinding(sessionFile); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index b0a5f04bffb..ce36a307908 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -3,6 +3,9 @@ import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { CodexAppServerApprovalPolicy, CodexAppServerSandboxMode } from "./config.js"; import type { CodexServiceTier } from "./protocol.js"; +const CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER = "openai-codex"; +const PUBLIC_OPENAI_MODEL_PROVIDER = "openai"; + export type CodexAppServerThreadBinding = { schemaVersion: 1; threadId: string; @@ -49,7 +52,10 @@ export async function readCodexAppServerBinding( cwd: typeof parsed.cwd === "string" ? parsed.cwd : "", authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined, model: typeof parsed.model === "string" ? parsed.model : undefined, - modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined, + modelProvider: normalizeCodexAppServerBindingModelProvider({ + authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined, + modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined, + }), approvalPolicy: readApprovalPolicy(parsed.approvalPolicy), sandbox: readSandboxMode(parsed.sandbox), serviceTier: readServiceTier(parsed.serviceTier), @@ -83,7 +89,10 @@ export async function writeCodexAppServerBinding( cwd: binding.cwd, authProfileId: binding.authProfileId, model: binding.model, - modelProvider: binding.modelProvider, + modelProvider: normalizeCodexAppServerBindingModelProvider({ + authProfileId: binding.authProfileId, + modelProvider: binding.modelProvider, + }), approvalPolicy: binding.approvalPolicy, sandbox: binding.sandbox, serviceTier: binding.serviceTier, @@ -111,6 +120,32 @@ function isNotFound(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +export function isCodexAppServerNativeAuthProfileId(authProfileId: string | undefined): boolean { + const normalized = authProfileId?.trim().toLowerCase(); + return Boolean( + normalized && + (normalized === CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER || + normalized.startsWith(`${CODEX_APP_SERVER_NATIVE_AUTH_PROVIDER}:`)), + ); +} + +export function normalizeCodexAppServerBindingModelProvider(params: { + authProfileId?: string; + modelProvider?: string; +}): string | undefined { + const modelProvider = params.modelProvider?.trim(); + if (!modelProvider) { + return undefined; + } + if ( + isCodexAppServerNativeAuthProfileId(params.authProfileId) && + modelProvider.toLowerCase() === PUBLIC_OPENAI_MODEL_PROVIDER + ) { + return undefined; + } + return modelProvider; +} + function readApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined { return value === "never" || value === "on-request" || diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index 504cac54058..576c1fb74d7 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -335,14 +335,20 @@ async function bindConversation( }; } const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig); - const data = await deps.startCodexConversationThread({ + const existingBinding = await deps.readCodexAppServerBinding(ctx.sessionFile); + const authProfileId = existingBinding?.authProfileId; + const startParams: Parameters[0] = { pluginConfig, sessionFile: ctx.sessionFile, workspaceDir, threadId: parsed.threadId, model: parsed.model, modelProvider: parsed.provider, - }); + }; + if (authProfileId) { + startParams.authProfileId = authProfileId; + } + const data = await deps.startCodexConversationThread(startParams); const binding = await deps.readCodexAppServerBinding(ctx.sessionFile); const threadId = binding?.threadId ?? parsed.threadId ?? "new thread"; const summary = `Codex app-server thread ${threadId} in ${workspaceDir}`; diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index dc6e7b74846..b3a9350b21e 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -1374,7 +1374,13 @@ describe("codex command", () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( `${sessionFile}.codex-app-server.json`, - JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }), + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-123", + cwd: "/repo", + authProfileId: "openai-codex:work", + modelProvider: "openai", + }), ); const startCodexConversationThread = vi.fn(async () => ({ kind: "codex-app-server-session" as const, @@ -1421,6 +1427,7 @@ describe("codex command", () => { threadId: "thread-123", model: "gpt-5.4", modelProvider: "openai", + authProfileId: "openai-codex:work", }); expect(requestConversationBinding).toHaveBeenCalledWith({ summary: "Codex app-server thread thread-123 in /repo", diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index c58e47f1485..5ef42eeb0cb 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -1,10 +1,18 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sharedClientMocks = vi.hoisted(() => ({ + getSharedCodexAppServerClient: vi.fn(), +})); + +vi.mock("./app-server/shared-client.js", () => sharedClientMocks); + import { handleCodexConversationBindingResolved, handleCodexConversationInboundClaim, + startCodexConversationThread, } from "./conversation-binding.js"; let tempDir: string; @@ -15,9 +23,58 @@ describe("codex conversation binding", () => { }); afterEach(async () => { + sharedClientMocks.getSharedCodexAppServerClient.mockReset(); await fs.rm(tempDir, { recursive: true, force: true }); }); + it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-old", + cwd: tempDir, + authProfileId: "openai-codex:work", + modelProvider: "openai", + }), + ); + const requests: Array<{ method: string; params: Record }> = []; + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async (method: string, requestParams: Record) => { + requests.push({ method, params: requestParams }); + return { + thread: { id: "thread-new", cwd: tempDir }, + model: "gpt-5.4-mini", + modelProvider: "openai", + }; + }), + }); + + await startCodexConversationThread({ + sessionFile, + workspaceDir: tempDir, + model: "gpt-5.4-mini", + modelProvider: "openai", + }); + + expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith( + expect.objectContaining({ authProfileId: "openai-codex:work" }), + ); + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + method: "thread/start", + params: expect.objectContaining({ model: "gpt-5.4-mini" }), + }); + expect(requests[0]?.params).not.toHaveProperty("modelProvider"); + await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain( + '"authProfileId": "openai-codex:work"', + ); + await expect( + fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"), + ).resolves.not.toContain('"modelProvider": "openai"'); + }); + it("clears the Codex app-server sidecar when a pending bind is denied", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const sidecar = `${sessionFile}.codex-app-server.json`; @@ -73,4 +130,76 @@ describe("codex conversation binding", () => { expect(result).toEqual({ handled: true }); }); + + it("returns a clean failure reply when app-server turn start rejects", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + await fs.writeFile( + `${sessionFile}.codex-app-server.json`, + JSON.stringify({ + schemaVersion: 1, + threadId: "thread-1", + cwd: tempDir, + authProfileId: "openai-codex:work", + }), + ); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ + request: vi.fn(async (method: string) => { + if (method === "turn/start") { + throw new Error( + "unexpected status 401 Unauthorized: Missing bearer or basic authentication in header", + ); + } + throw new Error(`unexpected method: ${method}`); + }), + addNotificationHandler: vi.fn(() => () => undefined), + addRequestHandler: vi.fn(() => () => undefined), + }); + + try { + const result = await handleCodexConversationInboundClaim( + { + content: "hi", + bodyForAgent: "hi", + channel: "telegram", + isGroup: false, + commandAuthorized: true, + }, + { + channelId: "telegram", + pluginBinding: { + bindingId: "binding-1", + pluginId: "codex", + pluginRoot: tempDir, + channel: "telegram", + accountId: "default", + conversationId: "5185575566", + boundAt: Date.now(), + data: { + kind: "codex-app-server-session", + version: 1, + sessionFile, + workspaceDir: tempDir, + }, + }, + }, + { timeoutMs: 50 }, + ); + await new Promise((resolve) => setImmediate(resolve)); + + expect(result).toEqual({ + handled: true, + reply: { + text: "Codex app-server turn failed: unexpected status 401 Unauthorized: Missing bearer or basic authentication in header", + }, + }); + expect(unhandledRejections).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); }); diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index 7896501c8bd..613f595817e 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -18,6 +18,8 @@ import { } from "./app-server/protocol.js"; import { clearCodexAppServerBinding, + isCodexAppServerNativeAuthProfileId, + normalizeCodexAppServerBindingModelProvider, readCodexAppServerBinding, writeCodexAppServerBinding, } from "./app-server/session-binding.js"; @@ -52,6 +54,7 @@ type CodexConversationStartParams = { threadId?: string; model?: string; modelProvider?: string; + authProfileId?: string; }; type BoundTurnResult = { @@ -77,6 +80,8 @@ export async function startCodexConversationThread( ): Promise { const workspaceDir = params.workspaceDir?.trim() || resolveCodexDefaultWorkspaceDir(params.pluginConfig); + const existingBinding = await readCodexAppServerBinding(params.sessionFile); + const authProfileId = params.authProfileId ?? existingBinding?.authProfileId; if (params.threadId?.trim()) { await attachExistingThread({ pluginConfig: params.pluginConfig, @@ -85,6 +90,7 @@ export async function startCodexConversationThread( workspaceDir, model: params.model, modelProvider: params.modelProvider, + authProfileId, }); } else { await createThread({ @@ -93,6 +99,7 @@ export async function startCodexConversationThread( workspaceDir, model: params.model, modelProvider: params.modelProvider, + authProfileId, }); } return createCodexConversationBindingData({ @@ -158,18 +165,24 @@ async function attachExistingThread(params: { workspaceDir: string; model?: string; modelProvider?: string; + authProfileId?: string; }): Promise { const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig }); + const modelProvider = resolveThreadRequestModelProvider({ + authProfileId: params.authProfileId, + modelProvider: params.modelProvider, + }); const client = await getSharedCodexAppServerClient({ startOptions: runtime.start, timeoutMs: runtime.requestTimeoutMs, + authProfileId: params.authProfileId, }); const response: CodexThreadResumeResponse = await client.request( CODEX_CONTROL_METHODS.resumeThread, { threadId: params.threadId, ...(params.model ? { model: params.model } : {}), - ...(params.modelProvider ? { modelProvider: params.modelProvider } : {}), + ...(modelProvider ? { modelProvider } : {}), approvalPolicy: runtime.approvalPolicy, approvalsReviewer: runtime.approvalsReviewer, sandbox: runtime.sandbox, @@ -182,8 +195,12 @@ async function attachExistingThread(params: { await writeCodexAppServerBinding(params.sessionFile, { threadId: thread.id, cwd: thread.cwd ?? params.workspaceDir, + authProfileId: params.authProfileId, model: response.model ?? params.model, - modelProvider: response.modelProvider ?? params.modelProvider, + modelProvider: normalizeCodexAppServerBindingModelProvider({ + authProfileId: params.authProfileId, + modelProvider: response.modelProvider ?? params.modelProvider, + }), approvalPolicy: runtime.approvalPolicy, sandbox: runtime.sandbox, serviceTier: runtime.serviceTier, @@ -196,18 +213,24 @@ async function createThread(params: { workspaceDir: string; model?: string; modelProvider?: string; + authProfileId?: string; }): Promise { const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig }); + const modelProvider = resolveThreadRequestModelProvider({ + authProfileId: params.authProfileId, + modelProvider: params.modelProvider, + }); const client = await getSharedCodexAppServerClient({ startOptions: runtime.start, timeoutMs: runtime.requestTimeoutMs, + authProfileId: params.authProfileId, }); const response: CodexThreadStartResponse = await client.request( "thread/start", { cwd: params.workspaceDir, ...(params.model ? { model: params.model } : {}), - ...(params.modelProvider ? { modelProvider: params.modelProvider } : {}), + ...(modelProvider ? { modelProvider } : {}), approvalPolicy: runtime.approvalPolicy, approvalsReviewer: runtime.approvalsReviewer, sandbox: runtime.sandbox, @@ -222,8 +245,12 @@ async function createThread(params: { await writeCodexAppServerBinding(params.sessionFile, { threadId: response.thread.id, cwd: response.thread.cwd ?? params.workspaceDir, + authProfileId: params.authProfileId, model: response.model ?? params.model, - modelProvider: response.modelProvider ?? params.modelProvider, + modelProvider: normalizeCodexAppServerBindingModelProvider({ + authProfileId: params.authProfileId, + modelProvider: response.modelProvider ?? params.modelProvider, + }), approvalPolicy: runtime.approvalPolicy, sandbox: runtime.sandbox, serviceTier: runtime.serviceTier, @@ -342,10 +369,29 @@ function enqueueBoundTurn(key: string, run: () => Promise): Promise { () => undefined, ); state.queues.set(key, queued); - void next.finally(() => { - if (state.queues.get(key) === queued) { - state.queues.delete(key); - } - }); + void next + .finally(() => { + if (state.queues.get(key) === queued) { + state.queues.delete(key); + } + }) + .catch(() => undefined); return next; } + +function resolveThreadRequestModelProvider(params: { + authProfileId?: string; + modelProvider?: string; +}): string | undefined { + const modelProvider = params.modelProvider?.trim(); + if (!modelProvider || modelProvider.toLowerCase() === "codex") { + return undefined; + } + if ( + isCodexAppServerNativeAuthProfileId(params.authProfileId) && + (modelProvider.toLowerCase() === "openai" || modelProvider.toLowerCase() === "openai-codex") + ) { + return undefined; + } + return modelProvider.toLowerCase() === "openai-codex" ? "openai" : modelProvider; +}