Files
rea/tests/boundary/mcp/webBundleContainmentMcp.test.ts
T
0155b89953 fix(browser): name web-bundle budgets and recover web deadlines (#6036)
* fix(browser): name web-bundle budgets and recover web deadlines

Name the existing heap, per-script, and total web-bundle phases, and reject a per-script deadline above the total. Promote capture, CDP command, source-map decode, and source-map fetch deadlines at today's defaults. Name DevTools timeoutMs, and state that discovery, cleanup grace, byte budgets, and Electron hook retention have no runtime override.

Refs #5910

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): apply selected web deadlines to the owning phase

Keep HTTP and browser-socket discovery on the fixed discovery deadline so a
short command_timeout_ms starts at the handshake. Apply scenario
command_timeout_ms to launch startup and the primary connect session, and
keep service-worker restoration on the fixed 5000 ms grace. Omit
command_timeout_ms from recovery text when the operation does not accept it.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): retain scenario commands that finish after their deadline

A timed-out scenario command stays owned so cleanup can close a late
browser or context and restore a mutation that lands after the deadline.
Child storage replies use that deadline, scenario recovery names
command_timeout_ms in the capture JSON, and the session test connection
implements beginCleanupGrace.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): keep child command timeouts as the public failure

A nested command such as Page.enable that is delivered but never answered
was stored as protocol_error, hiding the command name, selected deadline,
and recovery. Store the typed timeout and assert that public error.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): keep runtime cleanup on the fixed grace

Switch runtime restoration to the cleanup grace before the first send so a
short command_timeout_ms cannot fail an otherwise successful observation.
Stop advertising command_timeout_ms on list_browser_targets, which never
bounds a post-discovery command.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(browser): prove a short command deadline cannot fail runtime cleanup

Drive restoration through the runtime fixture. A cleanup command that
outlives command_timeout_ms still succeeds, and a stalled restoration
names the fixed 5000 ms grace.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the effective source-map decode deadline

codecBudgetLimitation reported a fixed 20-second deadline after a
caller selected decode_timeout_ms. Result and Evidence limitations
now name that effective millisecond deadline.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): separate handshake and target-attach deadlines

A browser-level Target.attachToTarget no longer shares the WebSocket
handshake budget. Blocked service-worker attachment keeps discovery on
its fixed deadline and applies command_timeout_ms to the later handshake
and attach.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the web-bundle resource each runtime exhausts

Bun ignores the V8 old-space flag. A Windows Job reports the process
commit limit, and other Bun hosts no longer suggest raising max_heap_mb.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): cite the browser-socket discovery message budget

WebSocket discovery enforces the 64 MiB protocol budget. Timeout
recovery no longer advertises the 8 MiB HTTP document budget.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): bound the blocked service-worker check before discovery

The first registration check has its own fixed 10s deadline. Discovery,
handshake, and Target.attachToTarget stay on their separate deadlines.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(browser): name the operand that stopped web-bundle admission

Host heap pressure no longer claims the fixed result budget is exhausted.
A Windows Bun allocation before the commit limit still names that limit.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 05:47:38 +08:00

161 lines
5.1 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, expect, it } from "vitest";
import { z } from "zod";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { ok as resultOk } from "../../../src/domain/result.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { observed } from "../../fixtures/analysisExecution.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
const capturedSource = `
fetch("/api/orders");
navigator.modelContext.registerTool({
name: "search_orders",
description: "Search orders",
inputSchema: {
type: "object",
properties: { query: { type: "string" } }
}
});
`;
const resources: Array<{ close(): Promise<unknown> }> = [];
const browsers: Array<{ close(): Promise<unknown> }> = [];
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
await Promise.all(browsers.splice(0).map((browser) => browser.close()));
});
it("retains timeout Evidence and recovers on the same MCP server", async () => {
const browser = await startFakeCdpBrowser({
commandResult: ({ method }) =>
method === "Debugger.getScriptSource"
? { scriptSource: capturedSource }
: undefined,
});
browsers.push(browser);
const session = createTestBinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(resultOk(null)),
}));
const server = createServer(
{ kind: "session", session },
{
browserObservation: new CdpBrowserProvider(),
availabilityPolicy: () => ({
processCaptureEnabled: false,
investigationInputRoots: 0,
}),
},
);
const client = new Client({
name: "web-bundle-containment-test",
version: "1",
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(client, server, session);
await server.connect(serverTransport);
await client.connect(clientTransport);
const interrupted = await client.callTool({
name: "analyze_web_bundle",
arguments: {
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
max_heap_mb: 128,
analysis_timeout_ms: 1,
total_analysis_timeout_ms: 1,
},
});
const timeout = parseMcpToolError(interrupted).error;
expect(timeout).toMatchObject({
code: "provider_timeout",
message: expect.stringContaining("total_analysis_timeout_ms"),
remediation: {
action: expect.stringContaining("total_analysis_timeout_ms"),
},
recovery: {
kind: "inspect_timeout_state",
operation: "analyze_web_bundle",
timeout_ms: 1,
},
});
const timeoutDetails = z
.object({ partial_observation: z.unknown() })
.passthrough()
.parse(timeout.details);
const partialReference = z
.object({
kind: z.literal("retained-evidence"),
evidence_id: z.string(),
})
.parse(timeoutDetails.partial_observation);
const retainedReply = await client.callTool({
name: "get_evidence_bundle",
arguments: {
detail: "complete",
},
});
expect(retainedReply.isError, JSON.stringify(retainedReply.content)).not.toBe(
true,
);
const retainedBundle = toolContract("get_evidence_bundle").outputSchema.parse(
retainedReply.structuredContent,
).result;
if ("kind" in retainedBundle)
throw new Error("Expected complete retained Evidence");
const retained = parseEvidence(
retainedBundle.records.find(
(record) => record.evidence_id === partialReference.evidence_id,
),
);
expect(retained.evidence_id).toBe(partialReference.evidence_id);
expect(retained.parameters).toMatchObject({
max_heap_mb: 128,
analysis_timeout_ms: 1,
total_analysis_timeout_ms: 1,
});
expect(retained.normalized_result).toMatchObject({
completeness: { status: "partial", parse_failures: 0 },
unknowns: expect.arrayContaining([
expect.objectContaining({ affected_script_keys: expect.any(Array) }),
]),
});
const recovered = await client.callTool({
name: "analyze_web_bundle",
arguments: {
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
observation_ms: 0,
},
});
expect(recovered.isError, JSON.stringify(recovered.content)).not.toBe(true);
expect(recovered.structuredContent).toMatchObject({
normalized_result: {
observations: {
endpoints: [expect.objectContaining({ value: "/api/orders" })],
webmcp_declarations: [
expect.objectContaining({
name: "search_orders",
trust: "page-declared-untrusted",
}),
],
},
},
});
await client.ping();
expect(browser.activeConnectionCount()).toBe(0);
}, 60_000);