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 | 105 | it.effect("a declined elicitation reaches the app-server as a decline, not an approval", () => |
| 82 | 106 | Effect.scoped( |
| 83 | 107 | Effect.gen(function* () { |
| @@ -17,6 +17,8 @@ · 16 unmodified lines | ||
| 17 | 17 | // |
| 18 | 18 | // MCP upstream app-server downstream |
| 19 | 19 | // initialize → initialize → initialized → thread/start |
| 20 | +// (with an approval policy that lets the | |
| 21 | +// plugin's own prompts reach the client) | |
| 20 | 22 | // tools/list → mcpServerStatus/list (one server's tools) |
| 21 | 23 | // tools/call → mcpServer/tool/call |
| 22 | 24 | // elicitation/create (to client) ← mcpServer/elicitation/request |
| @@ -277,7 +279,18 @@ class AppServerClientTransport implements Transport { · 254 unmodified lines | ||
| 277 | 279 | return; |
| 278 | 280 | } |
| 279 | 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 | 294 | if (!started.ok) { |
| 282 | 295 | this.#fail(message.id, started.error); |
| 283 | 296 | return; |
| @@ -29,6 +29,10 @@ const decodeInitializeParams = Schema.decodeUnknownOption( · 28 unmodified lines | ||
| 29 | 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 | 36 | const decodeThreadParams = Schema.decodeUnknownOption( |
| 33 | 37 | Schema.Struct({ |
| 34 | 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 | 83 | let initializedSeen = false; |
| 84 | +let elicitationsAllowed = false; | |
| 80 | 85 | let nextServerRequestId = 1000; |
| 81 | 86 | /** Elicitation request id → the pending tool call's request id. */ |
| 82 | 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 | 139 | return; |
| 135 | 140 | } |
| 136 | 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 | 151 | const elicitationId = nextServerRequestId++; |
| 138 | 152 | pendingApprovals.set(elicitationId, id); |
| 139 | 153 | write({ |
| @@ -200,6 +214,12 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => { · 60 unmodified lines | ||
| 200 | 214 | replyError(message.id, -32600, "thread/start before the initialized notification"); |
| 201 | 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 | 223 | reply(message.id, { thread: { id: THREAD_ID } }); |
| 204 | 224 | return; |
| 205 | 225 | } |