mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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.
This commit is contained in:
1 parent
1a91630b88
commit
63417bf3f0
3 files changed
+144
-3
No files matched your search
@@ -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) => ({
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -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 }) =>
|
||||
/\(\?<?[=!]|\\[1-9]|\\k</u.test(pattern),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(provider.scenarios).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
describe("browser scenario MCP tool", () => {
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
||||
|
||||
Reference in new issue
Block a user