Adds the first end-to-end coverage of a human approving/declining a gated MCP action in the rendered console approval page — the leg unit tests structurally cannot reach. - MCP surface: a session can run in elicitation_mode=browser, exposes awaitResume (the no-action long-poll resume), and parseBrowserApproval pulls the {executionId, approvalUrl} out of a paused result. - Surface fix: give each MCP session a unique mcporter server name. mcporter caches OAuth tokens per server name, so the old constant name let a later session reuse an earlier identity's token (wrong org). A decline scenario was the first identity-sensitive flow to expose it. - cloud/browser-approval.test.ts: approve runs the gated tool to completion; decline blocks it. Moves to scenarios/ once self-host and Cloudflare gain the feature.
Unified · Split
| @@ -0,0 +1,153 @@ | |||
| 1 | // Browser approval of a gated MCP action, end to end through the real console. | ||
| 2 | // | ||
| 3 | // A `require_approval` policy turns a built-in tool into an action that pauses | ||
| 4 | // for a human. The MCP session runs in `elicitation_mode=browser`, so the gated | ||
| 5 | // `execute` does not let the model resume inline — it pauses and hands back an | ||
| 6 | // `approvalUrl`. A real browser (signed in as the same identity) opens that | ||
| 7 | // console page and clicks Approve / Decline; meanwhile `resume` long-polls for | ||
| 8 | // the decision. Approve lets the tool run and return its result; Decline blocks | ||
| 9 | // it. This is the leg unit tests structurally cannot cover: a human clicking the | ||
| 10 | // button in the rendered ResumeApprovalPage. | ||
| 11 | // | ||
| 12 | // The policy is removed in an `ensuring` finalizer — a leaked require_approval | ||
| 13 | // gate on a shared built-in tool would pause unrelated scenarios. | ||
| 14 | // | ||
| 15 | // Lives under cloud/ for now because cloud is the only host wired for browser | ||
| 16 | // approval; it moves to scenarios/ (cross-target) as self-host and Cloudflare | ||
| 17 | // gain the feature. | ||
| 18 | import { expect } from "@effect/vitest"; | ||
| 19 | import { Effect } from "effect"; | ||
| 20 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 21 | |||
| 22 | import { scenario } from "../src/scenario"; | ||
| 23 | import { Api, Browser, Mcp, Target } from "../src/services"; | ||
| 24 | import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; | ||
| 25 | import type { BrowserSurface } from "../src/surfaces/browser"; | ||
| 26 | import type { Identity } from "../src/target"; | ||
| 27 | |||
| 28 | const coreApi = composePluginApi([] as const); | ||
| 29 | |||
| 30 | // Gating a built-in read tool keeps the scenario hermetic — no external server | ||
| 31 | // to host a destructive tool. The gate, not the tool, is what's under test: any | ||
| 32 | // action the engine pauses on flows through the same approval path. | ||
| 33 | const GATE_TOOL = "executor.coreTools.policies.list"; | ||
| 34 | |||
| 35 | // The gated call returns the policy listing, which includes the policy we just | ||
| 36 | // created — so the created policy's id appears in the result iff the tool | ||
| 37 | // actually ran (i.e. the human approved). | ||
| 38 | const GATED_CODE = ` | ||
| 39 | const result = await tools.executor.coreTools.policies.list({}); | ||
| 40 | return JSON.stringify(result); | ||
| 41 | `; | ||
| 42 | |||
| 43 | /** Open the console approval page as `identity` and click Approve or Decline. */ | ||
| 44 | const decideInBrowser = ( | ||
| 45 | browser: BrowserSurface, | ||
| 46 | identity: Identity, | ||
| 47 | approval: McpBrowserApproval, | ||
| 48 | decision: "Approve" | "Decline", | ||
| 49 | ): Effect.Effect<void> => | ||
| 50 | browser.session(identity, async ({ page, step }) => { | ||
| 51 | await step( | ||
| 52 | `Open the approval page and ${decision.toLowerCase()} the paused action`, | ||
| 53 | async () => { | ||
| 54 | await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); | ||
| 55 | await page.getByRole("button", { name: decision }).click(); | ||
| 56 | // The page confirms the decision was recorded ("Approve sent" / "Decline sent"). | ||
| 57 | await page.getByText(`${decision} sent`).waitFor(); | ||
| 58 | }, | ||
| 59 | ); | ||
| 60 | }); | ||
| 61 | |||
| 62 | scenario( | ||
| 63 | "MCP · a gated action approved in the browser runs to completion", | ||
| 64 | { timeout: 180_000 }, | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const target = yield* Target; | ||
| 67 | const api = yield* Api; | ||
| 68 | const browser = yield* Browser; | ||
| 69 | const mcp = yield* Mcp; | ||
| 70 | const identity = yield* target.newIdentity(); | ||
| 71 | const client = yield* api.client(coreApi, identity); | ||
| 72 | |||
| 73 | const policy = yield* client.policies.create({ | ||
| 74 | payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, | ||
| 75 | }); | ||
| 76 | |||
| 77 | yield* Effect.gen(function* () { | ||
| 78 | const session = mcp.session(identity, { elicitationMode: "browser" }); | ||
| 79 | const tools = yield* session.listTools(); | ||
| 80 | expect(tools).toContain("execute"); | ||
| 81 | |||
| 82 | const paused = yield* session.call("execute", { code: GATED_CODE }); | ||
| 83 | const approval = parseBrowserApproval(paused); | ||
| 84 | expect(approval.approvalUrl, "approval URL targets the resume page").toContain( | ||
| 85 | `/resume/${approval.executionId}`, | ||
| 86 | ); | ||
| 87 | |||
| 88 | // `resume` blocks for the human's decision; approve it in the browser | ||
| 89 | // concurrently, then the resumed call returns the gated tool's result. | ||
| 90 | const [resumed] = yield* Effect.all( | ||
| 91 | [ | ||
| 92 | session.awaitResume(approval.executionId), | ||
| 93 | decideInBrowser(browser, identity, approval, "Approve"), | ||
| 94 | ], | ||
| 95 | { concurrency: "unbounded" }, | ||
| 96 | ); | ||
| 97 | |||
| 98 | expect(resumed.ok, "the approved execution completed without error").toBe(true); | ||
| 99 | expect(resumed.text, "the gated tool ran and returned the policy listing").toContain( | ||
| 100 | policy.id, | ||
| 101 | ); | ||
| 102 | }).pipe( | ||
| 103 | Effect.ensuring( | ||
| 104 | client.policies | ||
| 105 | .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) | ||
| 106 | .pipe(Effect.ignore), | ||
| 107 | ), | ||
| 108 | ); | ||
| 109 | }), | ||
| 110 | ); | ||
| 111 | |||
| 112 | scenario( | ||
| 113 | "MCP · a gated action declined in the browser is blocked", | ||
| 114 | { timeout: 180_000 }, | ||
| 115 | Effect.gen(function* () { | ||
| 116 | const target = yield* Target; | ||
| 117 | const api = yield* Api; | ||
| 118 | const browser = yield* Browser; | ||
| 119 | const mcp = yield* Mcp; | ||
| 120 | const identity = yield* target.newIdentity(); | ||
| 121 | const client = yield* api.client(coreApi, identity); | ||
| 122 | |||
| 123 | const policy = yield* client.policies.create({ | ||
| 124 | payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, | ||
| 125 | }); | ||
| 126 | |||
| 127 | yield* Effect.gen(function* () { | ||
| 128 | const session = mcp.session(identity, { elicitationMode: "browser" }); | ||
| 129 | yield* session.listTools(); | ||
| 130 | |||
| 131 | const paused = yield* session.call("execute", { code: GATED_CODE }); | ||
| 132 | const approval = parseBrowserApproval(paused); | ||
| 133 | |||
| 134 | const [resumed] = yield* Effect.all( | ||
| 135 | [ | ||
| 136 | session.awaitResume(approval.executionId), | ||
| 137 | decideInBrowser(browser, identity, approval, "Decline"), | ||
| 138 | ], | ||
| 139 | { concurrency: "unbounded" }, | ||
| 140 | ); | ||
| 141 | |||
| 142 | // The decision propagated (resume returned rather than hanging) and the | ||
| 143 | // gated tool never ran — its output (the policy id) is absent. | ||
| 144 | expect(resumed.text, "the gated tool did not run after a decline").not.toContain(policy.id); | ||
| 145 | }).pipe( | ||
| 146 | Effect.ensuring( | ||
| 147 | client.policies | ||
| 148 | .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) | ||
| 149 | .pipe(Effect.ignore), | ||
| 150 | ), | ||
| 151 | ); | ||
| 152 | }), | ||
| 153 | ); | ||
| @@ -95,6 +95,42 @@ export interface McpCallResult { · 94 unmodified lines | |||
| 95 | readonly ok: boolean; | 95 | readonly ok: boolean; |
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | /** How a connection surfaces a paused (approval-gated) execution. `browser` is | ||
| 99 | * what the browser-approval scenarios drive: the pause yields an `approvalUrl` | ||
| 100 | * for a human to open instead of letting the model resume inline. */ | ||
| 101 | export type McpElicitationMode = "browser" | "model" | "native"; | ||
| 102 | |||
| 103 | /** The paused-execution handle a `browser`-mode call returns: the id to resume | ||
| 104 | * and the console URL a human opens to approve or decline it. */ | ||
| 105 | export interface McpBrowserApproval { | ||
| 106 | readonly executionId: string; | ||
| 107 | readonly approvalUrl: string; | ||
| 108 | } | ||
| 109 | |||
| 110 | /** | ||
| 111 | * Pull the `{ executionId, approvalUrl }` out of a `browser`-mode paused result. | ||
| 112 | * Throws if the call did not pause for approval (so a missing gate fails loudly | ||
| 113 | * rather than silently skipping the browser leg). | ||
| 114 | */ | ||
| 115 | export const parseBrowserApproval = (result: McpCallResult): McpBrowserApproval => { | ||
| 116 | const structured = (result.raw as { structuredContent?: unknown })?.structuredContent; | ||
| 117 | const record = (structured ?? {}) as { | ||
| 118 | status?: unknown; | ||
| 119 | executionId?: unknown; | ||
| 120 | approvalUrl?: unknown; | ||
| 121 | }; | ||
| 122 | if ( | ||
| 123 | record.status !== "user_approval_required" || | ||
| 124 | typeof record.executionId !== "string" || | ||
| 125 | typeof record.approvalUrl !== "string" | ||
| 126 | ) { | ||
| 127 | throw new Error( | ||
| 128 | `expected a browser approval-required result, got: ${JSON.stringify(structured)}`, | ||
| 129 | ); | ||
| 130 | } | ||
| 131 | return { executionId: record.executionId, approvalUrl: record.approvalUrl }; | ||
| 132 | }; | ||
| 133 | |||
| 98 | export interface McpSession { | 134 | export interface McpSession { |
| 99 | readonly listTools: () => Effect.Effect<ReadonlyArray<string>>; | 135 | readonly listTools: () => Effect.Effect<ReadonlyArray<string>>; |
| 100 | readonly call: (name: string, args?: Record<string, unknown>) => Effect.Effect<McpCallResult>; | 136 | readonly call: (name: string, args?: Record<string, unknown>) => Effect.Effect<McpCallResult>; |
| @@ -103,12 +139,21 @@ export interface McpSession { · 2 unmodified lines | |||
| 103 | text: string, | 139 | text: string, |
| 104 | content?: Record<string, unknown>, | 140 | content?: Record<string, unknown>, |
| 105 | ) => Effect.Effect<McpCallResult>; | 141 | ) => Effect.Effect<McpCallResult>; |
| 142 | /** | ||
| 143 | * Call `resume` with only an executionId — the browser-mode contract, where | ||
| 144 | * `resume` long-polls until a human records a decision through the console. | ||
| 145 | * Run this concurrently with the browser leg that approves/declines. | ||
| 146 | */ | ||
| 147 | readonly awaitResume: (executionId: string) => Effect.Effect<McpCallResult>; | ||
| 106 | } | 148 | } |
| 107 | 149 | ||
| 108 | export interface McpSurface { | 150 | export interface McpSurface { |
| 109 | /** The target's MCP endpoint — yield this surface to depend on it existing. */ | 151 | /** The target's MCP endpoint — yield this surface to depend on it existing. */ |
| 110 | readonly url: string; | 152 | readonly url: string; |
| 111 | readonly session: (identity: Identity) => McpSession; | 153 | readonly session: ( |
| 154 | identity: Identity, | ||
| 155 | options?: { readonly elicitationMode?: McpElicitationMode }, | ||
| 156 | ) => McpSession; | ||
| 112 | /** | 157 | /** |
| 113 | * Mint a real MCP bearer headlessly: protected-resource discovery → | 158 | * Mint a real MCP bearer headlessly: protected-resource discovery → |
| 114 | * authorization-server discovery → dynamic client registration → authorize | 159 | * authorization-server discovery → dynamic client registration → authorize |
| @@ -207,9 +252,20 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 92 unmodified lines | |||
| 207 | export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ({ | 252 | export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ({ |
| 208 | url: target.mcpUrl, | 253 | url: target.mcpUrl, |
| 209 | mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), | 254 | mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), |
| 210 | session: (identity) => { | 255 | session: (identity, options) => { |
| 211 | if (runDir) installTraceparentFetch(target.mcpUrl, runDir); | 256 | if (runDir) installTraceparentFetch(target.mcpUrl, runDir); |
| 212 | const serverName = target.name; | 257 | // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a |
| 258 | // constant name would let a later session reuse an earlier identity's token | ||
| 259 | // — landing in the wrong org. A unique name per session keeps each | ||
| 260 | // identity's OAuth isolated. The traceparent ledger keys off the URL, not | ||
| 261 | // this name, so it is unaffected. | ||
| 262 | const serverName = `${target.name}-${randomUUID().slice(0, 8)}`; | ||
| 263 | // `browser` mode is selected per the ecosystem convention — an | ||
| 264 | // `?elicitation_mode=` query on the MCP endpoint — so a paused execution | ||
| 265 | // yields an approvalUrl instead of letting the model resume inline. | ||
| 266 | const sessionUrl = options?.elicitationMode | ||
| 267 | ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` | ||
| 268 | : target.mcpUrl; | ||
| 213 | let runtimePromise: Promise<Runtime> | undefined; | 269 | let runtimePromise: Promise<Runtime> | undefined; |
| 214 | let connected = false; | 270 | let connected = false; |
| 215 | 271 | ||
| @@ -225,7 +281,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 9 unmodified lines | |||
| 225 | writeFileSync( | 281 | writeFileSync( |
| 226 | join(dir, "mcporter.json"), | 282 | join(dir, "mcporter.json"), |
| 227 | JSON.stringify({ | 283 | JSON.stringify({ |
| 228 | mcpServers: { [serverName]: { url: target.mcpUrl } }, | 284 | mcpServers: { [serverName]: { url: sessionUrl } }, |
| 229 | }), | 285 | }), |
| 230 | ); | 286 | ); |
| 231 | runtimePromise = createRuntime({ | 287 | runtimePromise = createRuntime({ |
| @@ -266,6 +322,9 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( · 34 unmodified lines | |||
| 266 | content: JSON.stringify(content), | 322 | content: JSON.stringify(content), |
| 267 | }); | 323 | }); |
| 268 | }), | 324 | }), |
| 325 | // No action argument: in browser mode `resume` blocks until the human's | ||
| 326 | // decision arrives via the console, then returns the resumed result. | ||
| 327 | awaitResume: (executionId) => call("resume", { executionId }), | ||
| 269 | }; | 328 | }; |
| 270 | }, | 329 | }, |
| 271 | }); | 330 | }); |