Unified · Split
| @@ -78,6 +78,30 @@ describe("codex app-server bridge", () => { · 77 unmodified lines | |||
| 78 | ), | 78 | ), |
| 79 | ); | 79 | ); |
| 80 | 80 | ||
| 81 | it.effect("starts the thread with an approval policy that lets prompts through", () => | ||
| 82 | Effect.scoped( | ||
| 83 | Effect.gen(function* () { | ||
| 84 | // Codex declines MCP elicitations ITSELF on a thread whose approval | ||
| 85 | // policy does not allow them — the prompt never reaches the client and | ||
| 86 | // the tool just reports "access was not approved". The fixture only | ||
| 87 | // elicits when the bridge asked for a permitting policy, so reaching | ||
| 88 | // the handler at all is the assertion. | ||
| 89 | const connection = yield* withConnection(appServerInput("messages")); | ||
| 90 | let prompted = false; | ||
| 91 | connection.client.setRequestHandler("elicitation/create", () => { | ||
| 92 | prompted = true; | ||
| 93 | return Promise.resolve({ action: "accept" as const, content: {} }); | ||
| 94 | }); | ||
| 95 | |||
| 96 | const result = yield* Effect.promise(() => | ||
| 97 | connection.client.callTool({ name: "needs_approval", arguments: {} }), | ||
| 98 | ); | ||
| 99 | expect(prompted, "the plugin's own approval prompt reached the client").toBe(true); | ||
| 100 | expect(result.isError).toBeFalsy(); | ||
| 101 | }), | ||
| 102 | ), | ||
| 103 | ); | ||
| 104 | |||
| 81 | it.effect("a declined elicitation reaches the app-server as a decline, not an approval", () => | 105 | it.effect("a declined elicitation reaches the app-server as a decline, not an approval", () => |
| 82 | Effect.scoped( | 106 | Effect.scoped( |
| 83 | Effect.gen(function* () { | 107 | Effect.gen(function* () { |
| @@ -17,6 +17,8 @@ · 16 unmodified lines | |||
| 17 | // | 17 | // |
| 18 | // MCP upstream app-server downstream | 18 | // MCP upstream app-server downstream |
| 19 | // initialize → initialize → initialized → thread/start | 19 | // initialize → initialize → initialized → thread/start |
| 20 | // (with an approval policy that lets the | ||
| 21 | // plugin's own prompts reach the client) | ||
| 20 | // tools/list → mcpServerStatus/list (one server's tools) | 22 | // tools/list → mcpServerStatus/list (one server's tools) |
| 21 | // tools/call → mcpServer/tool/call | 23 | // tools/call → mcpServer/tool/call |
| 22 | // elicitation/create (to client) ← mcpServer/elicitation/request | 24 | // elicitation/create (to client) ← mcpServer/elicitation/request |
| @@ -277,7 +279,18 @@ class AppServerClientTransport implements Transport { · 254 unmodified lines | |||
| 277 | return; | 279 | return; |
| 278 | } | 280 | } |
| 279 | this.#sendDownstream({ jsonrpc: "2.0", method: "initialized" }); | 281 | this.#sendDownstream({ jsonrpc: "2.0", method: "initialized" }); |
| 280 | const started = await this.#request("thread/start", { sessionStartSource: "startup" }); | 282 | // `approvalPolicy` is load-bearing, not a default worth inheriting: on a |
| 283 | // thread whose policy is `never` (or a granular one without | ||
| 284 | // `mcpElicitations`) Codex DECLINES every MCP elicitation itself and never | ||
| 285 | // forwards it, which surfaces as an unexplained "access was not approved" | ||
| 286 | // on any tool that asks — `read_messages` above all. `on-request` is the | ||
| 287 | // policy that routes the plugin's own approval prompt to the client, where | ||
| 288 | // executor's elicitation bridge answers it. Nothing else can escalate here: | ||
| 289 | // this thread runs no turns, so there is no shell or exec approval to ask. | ||
| 290 | const started = await this.#request("thread/start", { | ||
| 291 | sessionStartSource: "startup", | ||
| 292 | approvalPolicy: "on-request", | ||
| 293 | }); | ||
| 281 | if (!started.ok) { | 294 | if (!started.ok) { |
| 282 | this.#fail(message.id, started.error); | 295 | this.#fail(message.id, started.error); |
| 283 | return; | 296 | return; |
| @@ -29,6 +29,10 @@ const decodeInitializeParams = Schema.decodeUnknownOption( · 28 unmodified lines | |||
| 29 | Schema.Struct({ clientInfo: Schema.Struct({ name: Schema.String }) }), | 29 | Schema.Struct({ clientInfo: Schema.Struct({ name: Schema.String }) }), |
| 30 | ); | 30 | ); |
| 31 | 31 | ||
| 32 | const decodeThreadStartParams = Schema.decodeUnknownOption( | ||
| 33 | Schema.Struct({ approvalPolicy: Schema.optional(Schema.String) }), | ||
| 34 | ); | ||
| 35 | |||
| 32 | const decodeThreadParams = Schema.decodeUnknownOption( | 36 | const decodeThreadParams = Schema.decodeUnknownOption( |
| 33 | Schema.Struct({ | 37 | Schema.Struct({ |
| 34 | threadId: Schema.optional(Schema.String), | 38 | threadId: Schema.optional(Schema.String), |
| @@ -77,6 +81,7 @@ const replyError = (id: number | string, code: number, message: string): void => · 42 unmodified lines | |||
| 77 | }; | 81 | }; |
| 78 | 82 | ||
| 79 | let initializedSeen = false; | 83 | let initializedSeen = false; |
| 84 | let elicitationsAllowed = false; | ||
| 80 | let nextServerRequestId = 1000; | 85 | let nextServerRequestId = 1000; |
| 81 | /** Elicitation request id → the pending tool call's request id. */ | 86 | /** Elicitation request id → the pending tool call's request id. */ |
| 82 | const pendingApprovals = new Map<number | string, number | string>(); | 87 | const pendingApprovals = new Map<number | string, number | string>(); |
| @@ -134,6 +139,15 @@ const handleToolCall = (id: number | string, params: unknown): void => { · 51 unmodified lines | |||
| 134 | return; | 139 | return; |
| 135 | } | 140 | } |
| 136 | if (call.tool === "needs_approval") { | 141 | if (call.tool === "needs_approval") { |
| 142 | if (!elicitationsAllowed) { | ||
| 143 | // Exactly what Codex returns when it declines the elicitation for the | ||
| 144 | // client: an error result, no prompt, no explanation. | ||
| 145 | reply(id, { | ||
| 146 | content: [{ type: "text", text: "access was not approved" }], | ||
| 147 | isError: true, | ||
| 148 | }); | ||
| 149 | return; | ||
| 150 | } | ||
| 137 | const elicitationId = nextServerRequestId++; | 151 | const elicitationId = nextServerRequestId++; |
| 138 | pendingApprovals.set(elicitationId, id); | 152 | pendingApprovals.set(elicitationId, id); |
| 139 | write({ | 153 | write({ |
| @@ -200,6 +214,12 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { · 60 unmodified lines | |||
| 200 | replyError(message.id, -32600, "thread/start before the initialized notification"); | 214 | replyError(message.id, -32600, "thread/start before the initialized notification"); |
| 201 | return; | 215 | return; |
| 202 | } | 216 | } |
| 217 | // Codex DECLINES every MCP elicitation itself on a thread whose approval | ||
| 218 | // policy does not allow them, so a thread started without one can never | ||
| 219 | // receive an approval prompt. The fixture models that: it only elicits | ||
| 220 | // when the bridge asked for a policy that permits elicitations. | ||
| 221 | const params = Option.getOrUndefined(decodeThreadStartParams(message.params)); | ||
| 222 | elicitationsAllowed = params?.approvalPolicy === "on-request"; | ||
| 203 | reply(message.id, { thread: { id: THREAD_ID } }); | 223 | reply(message.id, { thread: { id: THREAD_ID } }); |
| 204 | return; | 224 | return; |
| 205 | } | 225 | } |