History

e2e: browser-approval scenarios on cloud + browser-mode MCP surface

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.

Rhys Sullivan committed 5 months ago

Commit e2b6bc51 parent 8d36fa22 files · +216 −4Browse files

Unified · Split

2 files
  1. e2e/cloud/browser-approval.test.ts +153 −0
  2. e2e/src/surfaces/mcp.ts +63 −4
e2e/cloud/browser-approval.test.ts+153−0
@@ -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.
18import { expect } from "@effect/vitest";
19import { Effect } from "effect";
20import { composePluginApi } from "@executor-js/api/server";
21
22import { scenario } from "../src/scenario";
23import { Api, Browser, Mcp, Target } from "../src/services";
24import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp";
25import type { BrowserSurface } from "../src/surfaces/browser";
26import type { Identity } from "../src/target";
27
28const 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.
33const 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).
38const GATED_CODE = `
39const result = await tools.executor.coreTools.policies.list({});
40return JSON.stringify(result);
41`;
42
43/** Open the console approval page as `identity` and click Approve or Decline. */
44const 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
62scenario(
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
112scenario(
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);
e2e/src/surfaces/mcp.ts+63−4
@@ -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. */
101export 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. */
105export 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 */
115export 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
98export interface McpSession {134export 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
108export interface McpSurface {150export 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 → authorize159 * authorization-server discovery → dynamic client registration → authorize
@@ -207,9 +252,20 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> => · 92 unmodified lines
207export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ({252export 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});