// Local-only — the MCP BROWSER-APPROVAL flow, the gap a code review found: // `resume.$executionId.tsx` POSTed to the bearer-gated `/api/mcp-sessions/*` // with no Authorization header, so standalone-web approvals 401'd. The existing // approval scenario (selfhost/mcp-approve.test.ts) approves PROGRAMMATICALLY via // the MCP `resume` tool (auth on the API path), so it never drives the browser // page and could not catch this. This drives the real page in a real browser. // // Flow: boot `executor web --foreground` → create a require_approval policy on a // built-in tool → an MCP client (bearer) executes that tool with // elicitation_mode=browser → the server returns a paused `approvalUrl` → open it // in the browser (with the `?_token` bootstrap) → click Approve → the MCP // `resume` call completes. Plus a negative: the approval endpoint 401s without // the bearer. import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { HttpApiClient } from "effect/unstable/httpapi"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { composePluginApi } from "@executor-js/api/server"; import { scenario } from "../src/scenario"; import { Browser, Cli, RunDir, Target } from "../src/services"; import { withLocalServer } from "./local-server"; const coreApi = composePluginApi([] as const); // A built-in, read-only tool to gate (same target the selfhost approval test // uses) — calling it under a require_approval policy forces the elicitation. const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; const EXECUTE_CODE = ` const result = await tools.executor.coreTools.policies.list({}); return JSON.stringify(result); `; scenario( "Local · MCP browser approval: a gated execution resumes after the human approves in the browser", { timeout: 180_000 }, Effect.gen(function* () { const cli = yield* Cli; const browser = yield* Browser; const target = yield* Target; const runDir = yield* RunDir; const identity = yield* target.newIdentity(); yield* withLocalServer(cli, runDir, (server) => Effect.gen(function* () { // Bearer-authed typed API client (local has no session cookie — the // credential is the printed token). Used only to plant the policy. const api = yield* HttpApiClient.make(coreApi, { baseUrl: new URL("/api", server.origin).toString(), transformClient: HttpClient.mapRequest((request) => HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), ), }).pipe(Effect.provide(FetchHttpClient.layer)); yield* api.policies.create({ payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" }, }); yield* browser.session(identity, async ({ page, step }) => { // MCP client over the wire with the bearer (local /mcp is bearer-gated, // not OAuth — so the raw SDK transport with an Authorization header, // not mcporter's PKCE flow). elicitation_mode=browser makes the server // mint an approval URL instead of a model-side pause. const mcp = new Client( { name: "e2e-local-approve", version: "1.0.0" }, { capabilities: {} }, ); const transport = new StreamableHTTPClientTransport( new URL(`${server.origin}/mcp?elicitation_mode=browser`), { requestInit: { headers: { authorization: `Bearer ${server.token}` } } }, ); await mcp.connect(transport); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the test owns the MCP transport lifecycle try { const executed = await mcp.callTool({ name: "execute", arguments: { code: EXECUTE_CODE }, }); const paused = executed.structuredContent as { status: string; executionId: string; approvalUrl: string; }; expect(paused.status, "execute paused for browser approval").toBe( "user_approval_required", ); expect(typeof paused.approvalUrl).toBe("string"); // The real human flow: approve in the browser FIRST. The page POSTs // the decision to the bearer-gated /api/mcp-sessions/* endpoint (the // path bug #2 left unauthenticated) — that just records the decision. // Calling the MCP `resume` tool first would un-pause the engine and // the page's getPaused would find nothing, so order matters. await step("Open the approval URL and approve in the browser", async () => { const approval = new URL(paused.approvalUrl); approval.searchParams.set("_token", server.token); // bootstrap the bearer await page.goto(approval.toString(), { waitUntil: "domcontentloaded" }); await page.getByRole("button", { name: "Approve" }).waitFor({ timeout: 30_000 }); // The page loaded the paused execution (bearer-authed) — not the // "unavailable" error branch a getPaused 401/404 would render. // (Playwright's toBeVisible matcher isn't in vitest's expect.) expect( await page.getByText("This paused execution is no longer available").count(), "approval page loaded the paused execution, not the unavailable branch", ).toBe(0); await page.getByRole("button", { name: "Approve" }).click(); // "Approve sent" only renders if the POST returned 200 — i.e. the // bearer reached the gated endpoint. Pre-fix it 401'd and stuck. await page.getByText("Approve sent").waitFor({ timeout: 15_000 }); }); // The agent's `resume` now picks up the recorded approval and the // engine finishes. const resumed = await mcp.callTool({ name: "resume", arguments: { executionId: paused.executionId }, }); const resumedStructured = resumed.structuredContent as { status: string }; expect( resumedStructured.status, "the MCP resume completed once the browser approved (bearer reached the gated endpoint)", ).toBe("completed"); await step("The approval endpoint rejects a request with no bearer", async () => { const unauthed = await fetch( `${server.origin}/api/mcp-sessions/${encodeURIComponent( paused.executionId, )}/executions/${encodeURIComponent(paused.executionId)}/resume?approval_token=x`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "accept" }), }, ); expect(unauthed.status, "no bearer → 401 at the shell gate").toBe(401); }); } finally { await mcp.close(); } }); }), ); }), );