From a7533e8330e1cbde9dc3852f90a633842e624332 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 17:58:56 +0200 Subject: [PATCH] fix(codex): honor full-access MCP elicitations Codex's never-ask policy accepts empty MCP forms, but Paseo routed those app-server requests into its permission channel. Mirror that provider contract while declining data-bearing forms that Paseo cannot answer. --- docs/providers.md | 5 + .../providers/codex-app-server-agent.test.ts | 100 ++++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 26 +++++ .../codex/test-utils/fake-app-server.ts | 8 ++ 4 files changed, 139 insertions(+) diff --git a/docs/providers.md b/docs/providers.md index a8e05895f8..d704fa80de 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -37,6 +37,11 @@ Each provider definition owns its option schema and exact MCP preapproval mappin must fail closed for Hub unattended execution until it can approve one exact injected MCP server and tool identity without approving native tools. +Codex **Full Access** sends `approvalPolicy: "never"` and `danger-full-access` to the app server. +If a permission-only MCP form still reaches the provider, Paseo accepts it when its schema has no +properties. Forms with fields and URL/auth flows are declined because Paseo cannot supply user data +or credentials. Restricted Codex modes keep their existing approval behavior. + ## Two Integration Patterns ### ACP (Agent Client Protocol) -- recommended diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index be08a231ce..d3916cef02 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -564,6 +564,32 @@ describe("Codex app-server provider", () => { } }); + test("launches full-access turns with Codex's no-approval unrestricted policy", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project", modeId: "full-access" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + await session.connect(); + await session.startTurn("run an ordinary command"); + + await expect(appServer.waitForThreadStart()).resolves.toMatchObject({ + approvalPolicy: "never", + sandbox: "danger-full-access", + }); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + approvalPolicy: "never", + sandboxPolicy: { type: "dangerFullAccess" }, + }); + } finally { + await session.close(); + } + }); + test("preapproves only granted tools on the injected Codex MCP server", async () => { const session = createSession({ modeId: undefined, @@ -1024,6 +1050,80 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("allows permission-only MCP elicitations in full-access mode", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project", modeId: "full-access" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + + await session.connect(); + session.subscribe((event) => events.push(event)); + + try { + appServer.requestMcpElicitation({ + threadId: "thread-1", + turnId: "turn-1", + serverName: "github", + message: "Allow Codex to use the GitHub connector?", + requestedSchema: { + type: "object", + properties: {}, + }, + }); + + await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({ + action: "accept", + content: {}, + _meta: null, + }); + expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" })); + } finally { + await session.close(); + } + }); + + test("does not fabricate data for a data-bearing full-access MCP elicitation", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project", modeId: "full-access" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + + await session.connect(); + session.subscribe((event) => events.push(event)); + + try { + appServer.requestMcpElicitation({ + threadId: "thread-1", + turnId: "turn-1", + serverName: "github", + message: "Enter the repository name to use.", + requestedSchema: { + type: "object", + properties: { + repository: { type: "string" }, + }, + }, + }); + + await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({ + action: "decline", + content: null, + _meta: null, + }); + expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" })); + } finally { + await session.close(); + } + }); + test("initializes Codex app-server without making Paseo the request originator", async () => { let initializeParams: unknown; const appServer = createFakeCodexAppServer({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 98f5aa8430..8cf672b329 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -830,6 +830,22 @@ function toObjectRecord(value: unknown): Record | undefined { return isRecord(value) ? value : undefined; } +function isPermissionOnlyMcpElicitationSchema(schema: unknown): boolean { + const schemaRecord = toObjectRecord(schema); + const properties = toObjectRecord(schemaRecord?.properties); + return properties !== undefined && Object.keys(properties).length === 0; +} + +function isCodexFullAccessMode(modeId: string, providerOptions: CodexProviderOptions): boolean { + return ( + modeId === "full-access" && + (providerOptions.approval_policy === undefined || + providerOptions.approval_policy === "never") && + (providerOptions.sandbox_mode === undefined || + providerOptions.sandbox_mode === "danger-full-access") + ); +} + // Codex app-server API response types interface CodexReasoningEffortEntry { reasoningEffort?: string; @@ -6414,6 +6430,16 @@ export class CodexAppServerAgentSession implements AgentSession { if (parsed.mode === "url") { return Promise.resolve({ action: "decline", content: null, _meta: null }); } + if (isCodexFullAccessMode(this.currentMode, this.providerOptions)) { + // Codex's never-ask policy accepts only empty MCP forms and declines + // forms that need data. Keep that provider contract when an app-server + // request still reaches Paseo; never fabricate form or auth values. + return Promise.resolve( + isPermissionOnlyMcpElicitationSchema(parsed.requestedSchema) + ? { action: "accept", content: {}, _meta: null } + : { action: "decline", content: null, _meta: null }, + ); + } const requiredFields = toObjectRecord(parsed.requestedSchema)?.required; if (Array.isArray(requiredFields) && requiredFields.length > 0) { return Promise.resolve({ action: "decline", content: null, _meta: null }); diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index dc04a31f09..21a59e11cf 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -48,6 +48,7 @@ export interface FakeCodexAppServer { readonly child: CodexAppServerChildProcess; readonly recordedRollbacks: JsonObject[]; assertNoErrors(): void; + waitForThreadStart(): Promise; waitForTurnStart(): Promise; nextResponse(): Promise; startsTurn(params: { threadId: string; turnId?: string }): void; @@ -297,6 +298,13 @@ export function createFakeCodexAppServer( throw errors[0]; } }, + async waitForThreadStart() { + const message = await waitForMessage( + (candidate) => candidate.method === "thread/start", + "thread start request", + ); + return toJsonObject(message.params); + }, async waitForTurnStart() { const message = await waitForMessage( (candidate) => candidate.method === "turn/start",