From 63417bf3f0857dbb07acaca5b0cf71046c9c0aa3 Mon Sep 17 00:00:00 2001 From: Nianze Wu Date: Mon, 26 Oct 2026 20:52:19 +0800 Subject: [PATCH] fix(contracts): publish portable browser request patterns (#7185) Publish portable browser request-path and HTTP-token patterns at their canonical owner, preserving input spelling and existing policy. Independently reproduced and verified through actual full-profile stdio MCP catalog and domain/MCP regressions on latest main. Closes #7168. --- src/domain/browserScenario.test.ts | 59 +++++++++++++ src/domain/browserScenarioValues.ts | 6 +- tests/boundary/mcp/browserScenarioMcp.test.ts | 82 ++++++++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/src/domain/browserScenario.test.ts b/src/domain/browserScenario.test.ts index 877c55bc07..a17607c6a9 100644 --- a/src/domain/browserScenario.test.ts +++ b/src/domain/browserScenario.test.ts @@ -1,6 +1,65 @@ import { expect, it } from "vitest"; import { browserScenarioSchema } from "./browserScenario.js"; +import { browserScenarioActionSchema } from "./browserScenarioValues.js"; + +const requestAction = (path: string, name = "X-Trace") => ({ + step_id: "request", + action: "request", + method: "GET", + path, + headers: [{ name, value: { source: "literal", value: "selected" } }], +}); + +it("preserves same-origin request path spelling and rejects network-path prefixes", () => { + for (const path of ["/", "/api?x=a%2Fb", "/api//item", "/a\\b"]) { + expect( + browserScenarioActionSchema.parse(requestAction(path)), + ).toMatchObject({ + path, + }); + } + for (const path of [ + "", + "relative", + "//other.test", + "/\\other.test", + "https://other.test/", + ]) + expect( + browserScenarioActionSchema.safeParse(requestAction(path)).success, + path, + ).toBe(false); +}); + +it("keeps HTTP token characters and forbidden-header policy distinct", () => { + const punctuation = new Set("!#$%&'*+-.^_`|~"); + for (let code = 0; code < 128; code += 1) { + const name = String.fromCharCode(code); + const isToken = + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + punctuation.has(name); + expect( + browserScenarioActionSchema.safeParse(requestAction("/api", name)) + .success, + JSON.stringify(name), + ).toBe(isToken); + } + const name = "X-Trace|Id-"; + expect( + browserScenarioActionSchema.parse(requestAction("/api", name)), + ).toMatchObject({ + headers: [{ name, value: { source: "literal", value: "selected" } }], + }); + for (const name of ["", "é", "Host", "cookie", "Proxy-Trace", "Sec-Trace"]) + expect( + browserScenarioActionSchema.safeParse(requestAction("/api", name)) + .success, + name, + ).toBe(false); +}); it("accepts large action, secret, and storage selections", () => { const actions = Array.from({ length: 129 }, (_, index) => ({ diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index 72d86241ce..ab3ff39580 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -141,7 +141,8 @@ const browserScenarioPageValueSchemas = [ const requestHeaderSchema = z.strictObject({ name: z .string() - .regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u) + // Hex escapes retain HTTP token characters in Unicode-set client regexes. + .regex(/^[!#$%&'*+.^_`\x7C~0-9A-Za-z\x2D]+$/u) .refine(settableRequestHeader, { message: "A page's fetch cannot set this header", }), @@ -319,7 +320,8 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ method: z.enum(BROWSER_SCENARIO_REQUEST_METHODS), path: z .string() - .regex(/^\/(?![/\\])/u) + // Preserve the prefix grammar without look-around or reserved class syntax. + .regex(/^\x2F(?:[^\x2F\x5C]|$)/u) .describe( "Path, and optional literal query, on the start URL's origin, such as /api/folders; it must resolve to that origin.", ), diff --git a/tests/boundary/mcp/browserScenarioMcp.test.ts b/tests/boundary/mcp/browserScenarioMcp.test.ts index e8caca9da6..bbcdd6abb7 100644 --- a/tests/boundary/mcp/browserScenarioMcp.test.ts +++ b/tests/boundary/mcp/browserScenarioMcp.test.ts @@ -1,5 +1,5 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, onTestFinished, test } from "vitest"; import { Ajv2020 } from "ajv/dist/2020.js"; import { z } from "zod"; @@ -178,6 +178,86 @@ const scenario = (origin = "https://app.example.test") => ({ ], }); +test.each(["full", "compact"] as const)( + "advertises portable browser action patterns (%s)", + async (profile) => { + const provider = new FakeBrowserScenarioProvider(); + const session = createTestBinarySession(() => { + throw new Error("Schema discovery must not start a deep provider"); + }); + const server = createServer( + { kind: "session", session }, + { + browserScenarioCapture: provider, + environment: { REA_MCP_INPUT_SCHEMA_PROFILE: profile }, + }, + ); + const client = new Client({ + name: "browser-action-patterns", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + onTestFinished(async () => { + await Promise.all([client.close(), server.close(), session.close()]); + }); + await server.connect(serverTransport); + await client.connect(clientTransport); + const tool = (await client.listTools()).tools.find( + ({ name }) => name === "capture_browser_scenario", + ); + if (tool === undefined) throw new Error("Missing browser scenario tool"); + const properties = z + .record(z.string(), z.unknown()) + .parse(tool.inputSchema.properties); + const pending = [{ value: properties.actions, where: "actions" }]; + const patterns: Array<{ where: string; pattern: string }> = []; + while (pending.length > 0) { + const selected = pending.pop(); + if ( + selected === undefined || + selected.value === null || + typeof selected.value !== "object" + ) + continue; + for (const [key, value] of Object.entries(selected.value)) { + const where = `${selected.where}/${key}`; + if (key === "pattern" && typeof value === "string") + patterns.push({ where, pattern: value }); + pending.push({ value, where }); + } + } + if (profile === "full") { + expect( + patterns.some(({ where }) => where.endsWith("/path/pattern")), + ).toBe(true); + expect( + patterns.some( + ({ where }) => + where.includes("/headers/") && where.endsWith("/name/pattern"), + ), + ).toBe(true); + } + const failures = patterns.flatMap(({ where, pattern }) => + ["", "u", "v"].flatMap((flags) => { + try { + new RegExp(pattern, flags); + return []; + } catch (cause: unknown) { + return [`${where} /${pattern}/${flags}: ${String(cause)}`]; + } + }), + ); + expect(failures).toEqual([]); + expect( + patterns.filter(({ pattern }) => + /\(\? { const resources: Array<{ close(): Promise }> = [];