← reports

#4645 · Codex MCP approvals stop before reaching thread interactions

BugPriority: MediumEffort: Mediumprovidersprovider-codexpartial-reproGitHub issue

October 1, 2026 · trusted origin/main 302847585840e2cce5f5ae1e3bc8967c45ff79b5

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high for the missing MCP elicitation handler; medium for its attribution to the reported Chrome action.

1. TL;DR

The real bb Codex bridge rejects MCP elicitation requests before creating a thread interaction. A focused protocol reproduction sends a valid form elicitation through a supervised app-server child: bb returns JSON-RPC error −32601 and emits zero runtime interaction requests. In the same harness and permission settings, a normal command approval produces an interaction and succeeds. Codex 0.159.3’s published source converts this category of client error into a decline, explaining the silent-denial mechanism if Browser Use sends this request. The reporter’s actual Chrome extension, browser message, stored site preferences, and auto-mode success were not exercised; the overall verdict is therefore partial rather than a claim of full end-to-end reproduction.

2. Claims vs findings

ClaimStatusEvidence
Provider approvals can end without any bb interaction.Verified at the bridge boundaryThe synthetic MCP form request returns −32601, with zero runtime interaction requests in both trusted checkouts.
Command approvals surface in accept-edits.Verified at the bridge boundaryPositive control forwards one command interaction with explicit user review and ask escalation.
The Chrome site-access request uses the failing MCP path.Unverified; plausible mechanismNo live Chrome extension or captured Browser Use traffic was available. The reproduction supplies a published MCP request shape, not an observed browser request.
The visible browser result says the user declined.Live message unverifiedStatic upstream evidence establishes error-to-decline mapping, but the actual browser tool message was not produced.
Auto mode works while accept-edits fails.Unverified end to endThe bridge’s missing handler itself is not conditional on permission mode. Automatic review can change which requests are produced upstream; this difference needs a live trace.
The standalone app prompts, and no saved block exists.UnverifiedNo access to the reporter’s app or browser preference store; neither was inspected.

3. Environment

4. Minimal reproduction

  1. Clone the trusted target repository and detach at the recorded commit; install and build the SDK.
  2. Save the two inline files below at their indicated paths. They exercise the existing production bridge and fake child transport; production code remains unchanged.
  3. Run the focused Turbo command. A nonzero exit is the expected proof of the missing interaction, not a setup failure.
  4. Run the three existing owner/sibling test files as controls.
  5. Repeat with a second clean worktree at the identical commit and a new temporary workspace. Use --force to prevent cached test results.
git clone https://github.com/get-bb/bb.git bb-4645-base
cd bb-4645-base
git fetch origin main
git checkout --detach 302847585840e2cce5f5ae1e3bc8967c45ff79b5
pnpm install --frozen-lockfile
pnpm exec turbo run build --filter=@get-bb/plugin-sdk

# Save the two files printed below in plugins/provider-codex/src/bridge/.
pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- src/bridge/issue-4645-elicitation.test.ts

pnpm exec turbo run test --filter=bb-plugin-provider-codex -- src/interactive-requests.test.ts src/session-params.test.ts src/bridge/bridge.calibration.test.ts

git worktree add --detach ../bb-4645-verify 302847585840e2cce5f5ae1e3bc8967c45ff79b5
cd ../bb-4645-verify
pnpm install --frozen-lockfile
# Copy the same two report files into plugins/provider-codex/src/bridge/.
pnpm exec turbo run test --filter=bb-plugin-provider-codex --force -- src/bridge/issue-4645-elicitation.test.ts

Expected

Command approval: one runtime interaction, an accepted child response.
MCP form elicitation: one runtime interaction rather than an immediate method-not-found error.

Actual, repeated in both checkouts

{"kind":"MCP form elicitation","interactions":0,"childResponses":[{"jsonrpc":"2.0","id":"fx-req-1","error":{"code":-32601,"message":"Unhandled codex request \"mcpServer/elicitation/request\""}}]}
✓ surfaces 'command control' as a runtime interaction
× surfaces 'MCP form elicitation' as a runtime interaction
AssertionError: expected [] to have a length of 1 but got +0
Tests: 1 failed | 1 passed (2)
Existing controls: 48 passed across 3 files.

The command’s successful control output is silenced by Vitest’s passed-only setting; its passing assertion proves forwarding. The failing elicitation prints the actual recorded child error. This is a protocol bug, not a visual screenshot reproduction.

plugins/provider-codex/src/bridge/issue-4645-record-app-server.mjs

import { spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { createInterface } from "node:readline";

const [fakePath, scriptPath, ioLogPath] = process.argv.slice(2);
const child = spawn(process.execPath, [fakePath, scriptPath], {
  stdio: ["pipe", "pipe", "pipe"],
});
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
const input = createInterface({ input: process.stdin });
input.on("line", (line) => {
  appendFileSync(ioLogPath, `${line}\n`);
  child.stdin.write(`${line}\n`);
});
input.on("close", () => child.stdin.end());
process.on("SIGTERM", () => child.kill("SIGTERM"));
child.on("close", (code) => process.exit(code ?? 0));

plugins/provider-codex/src/bridge/issue-4645-elicitation.test.ts

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { z } from "zod";
import { BRIDGE_INBOUND_REQUEST_METHODS } from "@get-bb/plugin-sdk/provider-bridge";
import {
  experimental_createBridgeJsonRpcTestHarness as createBridgeJsonRpcTestHarness,
  type BridgeJsonRpcTestHarness,
} from "@get-bb/plugin-sdk/provider-bridge/testing";
import { handleLine } from "./bridge.js";
import { waitForAppServerChildrenToExit } from "./bridge-process.test-support.js";

const options = {
  permissionMode: "accept-edits",
  permissionScope: "workspace",
  approvalReviewer: "user",
  permissionEscalation: "ask",
};

let harness: BridgeJsonRpcTestHarness;
let workspaceDir: string;
let processLogPath: string;

beforeEach(() => {
  workspaceDir = mkdtempSync(join(tmpdir(), "bb-4645-repro-"));
  processLogPath = join(workspaceDir, "process.log");
  harness = createBridgeJsonRpcTestHarness(handleLine);
});

afterEach(async () => {
  harness.sendRequest(99, "thread/stop", {
    threadId: "thr_issue_4645",
    providerThreadId: "cleanup",
    intent: "release",
    activeTurnId: null,
  });
  await harness.waitForResponse(99);
  await waitForAppServerChildrenToExit(processLogPath);
  harness.restore();
  vi.unstubAllEnvs();
  rmSync(workspaceDir, { recursive: true, force: true });
});

it.each([
  {
    kind: "command control",
    method: "item/commandExecution/requestApproval",
    params: {
      itemId: "command-4645",
      command: "pwd",
      cwd: "/tmp",
      commandActions: [],
      availableDecisions: ["accept", "decline"],
    },
  },
  {
    kind: "MCP form elicitation",
    method: "mcpServer/elicitation/request",
    params: {
      serverName: "repro-browser",
      mode: "form",
      _meta: null,
      message: "Approve the local test action",
      requestedSchema: {
        type: "object",
        properties: { approved: { type: "boolean" } },
        required: ["approved"],
      },
    },
  },
])(
  "surfaces $kind as a runtime interaction",
  async ({ kind, method, params }) => {
    const scriptPath = join(workspaceDir, "script.json");
    const ioLogPath = join(workspaceDir, "io.log");
    writeFileSync(
      scriptPath,
      JSON.stringify({
        processLogPath,
        turns: [
          [
            {
              kind: "request",
              method,
              params: {
                threadId: "native-thread",
                turnId: "turn-4645",
                ...params,
              },
            },
          ],
        ],
      }),
    );
    vi.stubEnv("BB_CODEX_BRIDGE_APP_SERVER_COMMAND", process.execPath);
    vi.stubEnv(
      "BB_CODEX_BRIDGE_APP_SERVER_ARGS",
      JSON.stringify([
        fileURLToPath(
          new URL("./issue-4645-record-app-server.mjs", import.meta.url),
        ),
        fileURLToPath(new URL("./fake-codex-app-server.mjs", import.meta.url)),
        scriptPath,
        ioLogPath,
      ]),
    );
    harness.sendRequest(1, "thread/start", {
      threadId: "thr_issue_4645",
      cwd: workspaceDir,
      instructionMode: "append",
      options,
    });
    const started = await harness.waitForResponse(1);
    expect(started.error).toBeUndefined();
    const { providerThreadId } = z
      .object({ providerThreadId: z.string() })
      .parse(started.result);
    harness.sendRequest(2, "turn/start", {
      threadId: "thr_issue_4645",
      providerThreadId,
      clientRequestId: "creq_abcdefgh23",
      input: [{ type: "text", text: "local protocol probe", mentions: [] }],
      options,
    });
    const deadline = Date.now() + 5_000;
    while (!harness.hasResponse(2) && Date.now() < deadline) {
      for (const message of harness.messages) {
        if (
          message.method === BRIDGE_INBOUND_REQUEST_METHODS.interactionRequest
        ) {
          handleLine(
            JSON.stringify({
              jsonrpc: "2.0",
              id: message.id,
              result: { decision: "allow_once", grantedPermissions: null },
            }),
          );
        }
      }
      await new Promise((resolveTick) => setTimeout(resolveTick, 20));
    }
    expect(harness.hasResponse(2)).toBe(true);
    expect((await harness.waitForResponse(2)).error).toBeUndefined();
    const interactions = harness.messages.filter(
      (message) =>
        message.method === BRIDGE_INBOUND_REQUEST_METHODS.interactionRequest,
    );
    const childResponses = readFileSync(ioLogPath, "utf8")
      .split("\n")
      .filter(Boolean)
      .map((line) => JSON.parse(line))
      .filter((message) => message.id === "fx-req-1");
    console.info(
      JSON.stringify({
        kind,
        interactions: interactions.length,
        childResponses,
      }),
    );
    expect(interactions).toHaveLength(1);
  },
);

5. Root cause

  1. decodeCodexInteractiveRequest recognizes command execution, file change, and permission-grant approvals only. It has no MCP elicitation case; its default returns null.
  2. handleChildRequest interprets that null as an unknown request and responds immediately with METHOD_NOT_FOUND. It returns before calling the runtime’s interaction/request path. Therefore the runtime cannot persist an interaction or show a user prompt for this request.
  3. Published Codex 0.159.3 ServerRequest includes mcpServer/elicitation/request. Its request contract includes form, OpenAI form, and URL variants with nullable turn context.
  4. Codex’s mcp_server_elicitation_response_from_client_result maps a client error such as −32601 to Decline; only its special turn-transition error handling produces Cancel. This portion is source-verified, not a locally executed Rust test.

That establishes the unsupported-request → no interaction → decline mechanism. It does not establish that the specific reported Chrome action emitted this request, nor which form/schema it uses. Missing support is upstream of the UI and CLI interaction list, so adding a visual prompt alone cannot repair it.

Trusted bb excerpt

if (decoded === null) {
  responder.error(
    BRIDGE_JSON_RPC_ERRORS.METHOD_NOT_FOUND,
    `Unhandled codex request "${method}"`,
  );
  return;
}

The session approval-policy mapping uses on-request for explicit user/ask settings. The reproduction verifies it can prompt for a command with the same session setup; denial policy, absence of a session, and invalid turn/start parameters are ruled out by the controls.

6. Proposed fix and safety decision

First capture a redacted live Browser Use app-server request in an isolated instance to establish the exact request method, variant, requested schema, and metadata. Then implement validated elicitation decoding, explicit runtime interaction routing, and the corresponding Codex response encoding; keep decision semantics and persistence separate from command/filesystem grants. Preserve requested form fields, action metadata, cancel/deny distinctions, nullable turn correlation, and site/session scope rather than treating every elicitation as a generic yes/no approval. Ensure equivalent SDK and CLI resolution and verify both user review and automatic-review paths without bypassing browser policy.

No automated fix or pull request: the overall reproduction is partial, and an elicitation repair affects a permission/security boundary and may need an interaction contract decision. Those changes are explicitly excluded from this rule’s simple-fix allowance. No production files, public protocol, permission policy, dependency, or stored data were changed; no fix branch was created or pushed.

7. Related issues and pull requests

No linked pull request appeared in the issue’s connected/cross-referenced timeline, and no open pull request matched the numeric issue lookup during investigation. Related issue searches found #3849 (ACP elicitation capabilities) and #4530 (OpenCode questions); both concern different provider paths, so neither is asserted to duplicate this Codex defect. No PR code was checked out or executed.

8. Verification

The same agent repeated the finalized reproduction in a second clean detached worktree at 302847585840e2cce5f5ae1e3bc8967c45ff79b5, with its own frozen install, uncached Turbo execution, newly spawned fake app-server children, and fresh temporary test directories. The first run at 21:25:24 UTC and the second run at 21:25:49 UTC both produced one passing command control, one failing MCP elicitation assertion, zero elicitation interactions, and the identical −32601 error. This is repeat verification by the same investigator, not an independent review.

The existing interactive-request, session-parameter, and bridge-calibration suites then passed all 48 tests at 21:26:00 UTC. Earlier draft probes were corrected for the required clientRequestId and its schema before either finalized run; those setup errors are not counted as reproduction evidence. There were no corrections to the final findings after the second run.

9. Appendix

The exact final test source, recording relay, commands, and expected-versus-actual output are inline above so this report is reproducible without downloading hidden artifacts. Sanitized raw logs and the source artifacts are retained outside the public reports repository; the repository forbids publishing logs or test artifact directories. No live dev app was started, no user runtime store was opened, and test cleanup verified child exit. The original project checkout remains untouched.

Test-authoring audit: this owner-boundary regression protects delivery of supported provider approvals; omitting an elicitation handler causes it to fail. Existing tests cover command/file/permission requests but not MCP elicitation, so the failure was previously unguarded. The test exercises the real bridge and child connection using existing production interfaces and an existing fake app-server; the recorder only captures responses and does not implement the expected approval behavior.

Issue content was treated as untrusted claims. No issue command, URL, script, patch, branch, or instruction was executed. GitHub classification and labeling are performed through the supplied SlopCop writer, and publication does not post an extra issue comment.

> AGENT GENERATED