Unified · Split
| @@ -15,13 +15,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 14 unmodified lines | |||
| 15 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 15 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 16 | 16 | ||
| 17 | import { scenario } from "../src/scenario"; | 17 | import { scenario } from "../src/scenario"; |
| 18 | import { Api, Mcp, Target } from "../src/services"; | 18 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 19 | import { parseBrowserApproval } from "../src/surfaces/mcp"; | 19 | import { parseBrowserApproval } from "../src/surfaces/mcp"; |
| 20 | import type { Identity } from "../src/target"; | 20 | import type { Identity } from "../src/target"; |
| 21 | 21 | ||
| 22 | const coreApi = composePluginApi([] as const); | 22 | const coreApi = composePluginApi([] as const); |
| 23 | 23 | ||
| 24 | const GATE_TOOL = "executor.coreTools.policies.list"; | 24 | const GATE_TOOL = "executor.coreTools.policies.list"; |
| 25 | const UNAVAILABLE_COPY = "This paused execution is no longer available"; | ||
| 25 | 26 | ||
| 26 | const GATED_CODE = ` | 27 | const GATED_CODE = ` |
| 27 | const result = await tools.executor.coreTools.policies.list({}); | 28 | const result = await tools.executor.coreTools.policies.list({}); |
| @@ -63,6 +64,11 @@ const openBrowserApprovalSession = async (mcpUrl: string, bearer: string): Promi · 35 unmodified lines | |||
| 63 | const closeQuietly = (connected: Connected): Effect.Effect<void> => | 64 | const closeQuietly = (connected: Connected): Effect.Effect<void> => |
| 64 | Effect.promise(() => connected.client.close().catch(() => undefined)); | 65 | Effect.promise(() => connected.client.close().catch(() => undefined)); |
| 65 | 66 | ||
| 67 | const pathWithSearch = (url: string): string => { | ||
| 68 | const parsed = new URL(url); | ||
| 69 | return `${parsed.pathname}${parsed.search}`; | ||
| 70 | }; | ||
| 71 | |||
| 66 | const authenticatedFetch = ( | 72 | const authenticatedFetch = ( |
| 67 | identity: Identity, | 73 | identity: Identity, |
| 68 | input: URL, | 74 | input: URL, |
| @@ -167,3 +173,97 @@ scenario( · 98 unmodified lines | |||
| 167 | ); | 173 | ); |
| 168 | }), | 174 | }), |
| 169 | ); | 175 | ); |
| 176 | |||
| 177 | scenario( | ||
| 178 | "MCP approval · browser resume page approves a paused execution through the UI", | ||
| 179 | { timeout: 180_000 }, | ||
| 180 | Effect.gen(function* () { | ||
| 181 | const target = yield* Target; | ||
| 182 | const { client: apiClient } = yield* Api; | ||
| 183 | const browser = yield* Browser; | ||
| 184 | const mcp = yield* Mcp; | ||
| 185 | const identity = yield* target.newIdentity(); | ||
| 186 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 187 | const api = yield* apiClient(coreApi, identity); | ||
| 188 | |||
| 189 | const policy = yield* api.policies.create({ | ||
| 190 | payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, | ||
| 191 | }); | ||
| 192 | |||
| 193 | yield* Effect.gen(function* () { | ||
| 194 | const session = yield* Effect.promise(() => | ||
| 195 | openBrowserApprovalSession(target.mcpUrl, bearer), | ||
| 196 | ); | ||
| 197 | yield* Effect.gen(function* () { | ||
| 198 | const paused = yield* Effect.promise(() => | ||
| 199 | session.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }), | ||
| 200 | ); | ||
| 201 | const approval = parseBrowserApproval({ | ||
| 202 | raw: paused, | ||
| 203 | text: textOf(paused), | ||
| 204 | ok: paused.isError !== true, | ||
| 205 | }); | ||
| 206 | |||
| 207 | const approvalUrl = new URL(approval.approvalUrl); | ||
| 208 | const mcpSessionId = approvalUrl.searchParams.get("mcp_session_id"); | ||
| 209 | expect( | ||
| 210 | mcpSessionId, | ||
| 211 | "approval URL carries the MCP session id that the browser page will query", | ||
| 212 | ).toEqual(expect.any(String)); | ||
| 213 | expect(mcpSessionId, "approval URL points at the session that paused").toBe( | ||
| 214 | session.transport.sessionId, | ||
| 215 | ); | ||
| 216 | |||
| 217 | const [resumed] = yield* Effect.all( | ||
| 218 | [ | ||
| 219 | Effect.promise(() => | ||
| 220 | session.client.callTool({ | ||
| 221 | name: "resume", | ||
| 222 | arguments: { executionId: approval.executionId }, | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | browser.session(identity, async ({ page, step }) => { | ||
| 226 | await step("Open the paused execution approval page", async () => { | ||
| 227 | await page.goto(pathWithSearch(approval.approvalUrl), { waitUntil: "networkidle" }); | ||
| 228 | await page.getByText("User approval required").waitFor(); | ||
| 229 | }); | ||
| 230 | |||
| 231 | await step("Review the paused tool call details", async () => { | ||
| 232 | await page.getByText("Pending request").waitFor(); | ||
| 233 | await page.getByText(/Approve executor\.coreTools\.policies\.list\?/).waitFor(); | ||
| 234 | |||
| 235 | const approve = page.getByRole("button", { name: "Approve" }); | ||
| 236 | await approve.waitFor(); | ||
| 237 | expect( | ||
| 238 | await approve.isEnabled(), | ||
| 239 | "the approve control is enabled for the paused execution", | ||
| 240 | ).toBe(true); | ||
| 241 | expect( | ||
| 242 | await page.getByText(UNAVAILABLE_COPY).count(), | ||
| 243 | "the resume page does not show the expired-session failure copy", | ||
| 244 | ).toBe(0); | ||
| 245 | }); | ||
| 246 | |||
| 247 | await step("Approve the paused tool call", async () => { | ||
| 248 | await page.getByRole("button", { name: "Approve" }).click(); | ||
| 249 | await page.getByText("Approve sent").waitFor(); | ||
| 250 | }); | ||
| 251 | }), | ||
| 252 | ], | ||
| 253 | { concurrency: "unbounded" }, | ||
| 254 | ); | ||
| 255 | |||
| 256 | expect(resumed.isError, "browser-mode resume completed after the UI approval").not.toBe( | ||
| 257 | true, | ||
| 258 | ); | ||
| 259 | expect(textOf(resumed), "the gated tool completed after approval").toContain(policy.id); | ||
| 260 | }).pipe(Effect.ensuring(closeQuietly(session))); | ||
| 261 | }).pipe( | ||
| 262 | Effect.ensuring( | ||
| 263 | api.policies | ||
| 264 | .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) | ||
| 265 | .pipe(Effect.ignore), | ||
| 266 | ), | ||
| 267 | ); | ||
| 268 | }), | ||
| 269 | ); | ||
Binary file not shown.