fix(browser): preserve form destinations and satisfies mutation uncertainty (#825)

Resolve empty form destinations from the captured document URL while preserving
base-relative links and Chrome's form/control whitespace distinction. Follow
satisfies wrappers when propagating explicit member mutation paths.

Add fail-before regressions and real-browser CLI/MCP verification.

Fixes #820

Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis)
Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
N0zoM1z0 authored and GitHub committed 2026-10-07 11:22:03 +08:00
1 parent 3ed59b2812
commit 6dd719eaf6
11 files changed
+272 -4

No files matched your search

+6
View File
@@ -93,6 +93,12 @@ installed Chrome-family browser. An optional script argument selects an already
installed package's `scripts/rea.mjs` entry point for packaged-artifact checks.
The complete `verify:browser` lane includes these same checks.
After building, `verify:browser:dom` checks empty and HTML-whitespace form
destinations against a native Chrome DOM-property oracle through CLI and stdio
MCP. It requires `REA_BROWSER_EXECUTABLE` and accepts an optional installed REA
entrypoint. The full browser lane includes the same public-adapter assertions
before other fixtures navigate the selected page.
`verify:browser:scripts` checks active script capture → exact-byte export →
existing static JavaScript analysis through CLI and stdio MCP, including
manifest readback, competing query variants, and resolved relative imports.
+1
View File
@@ -132,6 +132,7 @@
"verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format",
"verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs",
"verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs",
"verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs",
"verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs",
"verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs",
"verify:inspector": "npm run build:cached && node scripts/verify-real-javascript-runtime-observation.mjs",
+1 -1
View File
@@ -57,7 +57,7 @@ export async function startBrowserVerifierSite() {
"rea-app-cookie=app-fixture; Path=/app; SameSite=Lax",
]);
response.end(
'<!doctype html><html><head><title>REA browser verifier</title><base href="assets/"></head><body><main><h1>Browser evidence</h1><a href="guide">Guide</a><button aria-label="ax-private-label-value">Verify</button></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
'<!doctype html><html><head><title>REA browser verifier</title><base href="assets/"></head><body><main><h1>Browser evidence</h1><a href="guide">Guide</a><button aria-label="ax-private-label-value">Verify</button><form action=""></form><form action=" "></form><button formaction=""></button><input formaction=""></main><script type="module" src="/app.js?token=script-query-secret"></script></body></html>',
);
});
const webSockets = new WebSocketServer({ noServer: true });
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { parseEvidence } from "../../dist/domain/evidence.js";
import { assertFormDestinations } from "./browser-verifier-assertions.mjs";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
/** Verify the real fixture's form destinations through CLI and stdio MCP. */
export async function verifyBrowserDomDestinations(
attachment,
entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)),
documentUrl,
) {
const env = { ...process.env, REA_LOG_LEVEL: "silent" };
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
"inspect-web-page",
attachment.cdp_endpoint,
attachment.target_id,
"--observation-ms",
"100",
"--json",
],
{ env, timeout: 60000, maxBuffer: 32 * 1024 * 1024 },
);
assertFormDestinations(
parseEvidence(JSON.parse(stdout)).normalized_result,
documentUrl,
);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "browser-dom-destinations", version: "1" });
try {
await client.connect(transport);
const result = await client.callTool({
name: "inspect_web_page",
arguments: { ...attachment, observation_ms: 100 },
});
assert.notEqual(result.isError, true, mcpTextValue(result));
assertFormDestinations(
parseEvidence(JSON.parse(mcpTextValue(result)).evidence)
.normalized_result,
documentUrl,
);
return { cli: true, stdio_mcp: true, current_document_destinations: 4 };
} finally {
await client.close();
await transport.close();
}
}
@@ -87,6 +87,7 @@ function assertObservationPrivacy(result) {
}
function assertObservationMetadata(result, origin) {
assertFormDestinations(result);
if (
!result.metadata.dom_urls.some(
({ attribute, url }) =>
@@ -121,6 +122,23 @@ function assertObservationMetadata(result, origin) {
throw new Error("Real Chrome safe response metadata was missing");
}
/** Assert the fixture's empty form destinations retain the current document URL. */
export function assertFormDestinations(
result,
documentUrl = result.target.url,
) {
const destinations = result.metadata.dom_urls.filter(
({ attribute }) => attribute === "action" || attribute === "formaction",
);
if (
destinations.length !== 4 ||
destinations.some(({ url }) => url !== documentUrl)
)
throw new Error(
`Real Chrome form destinations did not use the current document URL: ${JSON.stringify({ expected: documentUrl, target: result.target.url, destinations })}`,
);
}
/** Assert real-browser static bundle and source-map findings. */
export function assertBundleAnalysis(result) {
if (
@@ -0,0 +1,53 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs";
import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
const executable = process.env.REA_BROWSER_EXECUTABLE;
if (!executable)
throw new Error("verify:browser:dom requires REA_BROWSER_EXECUTABLE");
const profile = await mkdtemp(join(tmpdir(), "rea-browser-dom-"));
const site = await startBrowserVerifierSite();
let context;
try {
context = await chromium.launchPersistentContext(profile, {
executablePath: executable,
headless: true,
args: ["--remote-debugging-port=0"],
});
const page = context.pages()[0] ?? (await context.newPage());
await page.goto(`${site.origin}/app?selected=fixture#section`);
const native = await page.evaluate(() =>
[
...document.querySelectorAll("form,button[formaction],input[formaction]"),
].map((element) =>
element.tagName === "FORM" ? element.action : element.formAction,
),
);
assert.deepEqual(native, Array(4).fill(page.url()));
const session = await context.newCDPSession(page);
const { targetInfo } = await session.send("Target.getTargetInfo");
await session.detach();
const [port] = (
await readFile(join(profile, "DevToolsActivePort"), "utf8")
).split("\n");
const result = await verifyBrowserDomDestinations(
{
cdp_endpoint: `http://127.0.0.1:${port}`,
target_id: targetInfo.targetId,
},
process.argv[2],
page.url(),
);
process.stdout.write(
`${JSON.stringify({ ...result, browser: context.browser().version(), native_oracle: true, verified: true })}\n`,
);
} finally {
await context?.close();
await site.close();
await rm(profile, { recursive: true, force: true });
}
+6
View File
@@ -37,6 +37,7 @@ import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs";
import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs";
import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs";
import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs";
import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs";
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000;
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
@@ -98,6 +99,10 @@ try {
);
if (!observed.ok) throw observed.error;
assertObservation(observed.value, site.origin);
const domDestinations = await verifyBrowserDomDestinations({
cdp_endpoint: endpoint,
target_id: target,
});
const serialized = JSON.stringify(observed.value);
if (
!observed.value.target.url.includes("startup=browser-secret-value") ||
@@ -352,6 +357,7 @@ try {
popupEvents,
networkEvidence,
scriptExport,
domDestinations,
browserScenarioCli: true,
browserScenarioAttachCleanup: "disconnected-external",
browserScenarioLaunchCleanup: "terminated-owned-process",
@@ -113,3 +113,81 @@ describe("DOM metadata document base URLs", () => {
expect(result.agentHints).toEqual([]);
});
});
describe("empty form destinations with a document base URL", () => {
it.each([
{ nodeName: "FORM", attribute: "action" },
{ nodeName: "BUTTON", attribute: "formaction" },
{ nodeName: "INPUT", attribute: "formaction" },
])(
"resolves an empty $nodeName $attribute to the document",
({ nodeName, attribute }) => {
const pageUrl = `${documentUrl}?selected=fixture#section`;
const baseUrl = `${origin}/assets/`;
const value = snapshot(baseUrl, "/assets/", pageUrl, "");
value.strings[6] = nodeName;
value.strings[14] = attribute;
value.strings[15] = "";
const result = capture(value);
expect(result.urls[3]).toMatchObject({
attribute,
url: pageUrl,
destination_scope: "approved",
});
expect(result.urls[1]?.url).toBe(baseUrl);
},
);
it("keeps an empty form action approved when the document base is foreign", () => {
const foreign = "https://cdn.example.test/assets/";
const value = snapshot(foreign, foreign);
value.strings[15] = "";
expect(capture(value).urls[3]).toMatchObject({
url: documentUrl,
destination_scope: "approved",
});
});
it.each([" ", "\t\r\n\f"])(
"resolves an HTML-whitespace-only form action to the document: %j",
(action) => {
const baseUrl = `${origin}/assets/`;
const value = snapshot(baseUrl);
value.strings[15] = action;
expect(capture(value).urls[3]?.url).toBe(documentUrl);
},
);
it.each(["BUTTON", "INPUT"])(
"retains document-base resolution for a whitespace-only %s formaction",
(nodeName) => {
const baseUrl = `${origin}/assets/`;
const value = snapshot(baseUrl);
value.strings[6] = nodeName;
value.strings[14] = "formaction";
value.strings[15] = " ";
expect(capture(value).urls[3]?.url).toBe(baseUrl);
},
);
it.each([
{ value: "\u000b", suffix: "" },
{ value: "\u00a0", suffix: "%C2%A0" },
])(
"does not treat non-HTML whitespace as an empty form action: $value",
({ value: action, suffix }) => {
const baseUrl = `${origin}/assets/`;
const value = snapshot(baseUrl);
value.strings[15] = action;
expect(capture(value).urls[3]?.url).toBe(`${baseUrl}${suffix}`);
},
);
it("does not apply form semantics to an action attribute on another element", () => {
const baseUrl = `${origin}/assets/`;
const value = snapshot(baseUrl);
value.strings[6] = "DIV";
value.strings[15] = "";
expect(capture(value).urls[3]?.url).toBe(baseUrl);
});
});
+26 -3
View File
@@ -194,6 +194,7 @@ export const captureDom = (
attributes: attributeIndexes,
// The BASE element itself resolves against the document fallback URL.
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
documentUrl,
nodeIndex,
nodeName,
allowedOrigins,
@@ -363,6 +364,7 @@ interface DomMetadataOptions {
readonly strings: readonly string[];
readonly attributes: readonly number[];
readonly baseUrl: string;
readonly documentUrl: string;
readonly nodeIndex: number;
readonly nodeName: string;
readonly allowedOrigins: ReadonlySet<string>;
@@ -374,8 +376,15 @@ const domMetadata = (
readonly urls: WebPageInspection["metadata"]["dom_urls"];
readonly agentHints: WebPageInspection["metadata"]["agent_hints"];
} => {
const { strings, attributes, baseUrl, nodeIndex, nodeName, allowedOrigins } =
options;
const {
strings,
attributes,
baseUrl,
documentUrl,
nodeIndex,
nodeName,
allowedOrigins,
} = options;
const pairs = new Map<string, string>();
for (let index = 0; index + 1 < attributes.length; index += 2) {
const name = indexedString(strings, attributes[index]).toLowerCase();
@@ -386,7 +395,21 @@ const domMetadata = (
for (const attribute of domUrlAttributes) {
const value = pairs.get(attribute);
if (value === undefined) continue;
const destination = domDestination(value, baseUrl, allowedOrigins);
const tagName = nodeName.toLowerCase();
// Chrome strips HTML whitespace for FORM.action, while submit controls
// use the document URL only for an exactly empty formaction attribute.
const usesDocumentUrl =
(tagName === "form" &&
attribute === "action" &&
/^[\t\n\f\r ]*$/u.test(value)) ||
((tagName === "button" || tagName === "input") &&
attribute === "formaction" &&
value === "");
const destination = domDestination(
usesDocumentUrl ? documentUrl : value,
baseUrl,
allowedOrigins,
);
urls.push({
node_index: nodeIndex,
attribute,
@@ -115,3 +115,25 @@ describe("JavaScript semantic values for properties unaffected by a mutation", (
).toBe("unknown");
});
});
describe("property mutations through TypeScript satisfies aliases", () => {
it.each([
'const shared = { mode: "initial" }; const alias = shared satisfies { mode: string }; alias.mode = "updated"; return shared.mode;',
'const shared = { nested: { mode: "initial" } }; const alias = shared satisfies { nested: { mode: string } }; alias.nested.mode = "updated"; return shared.nested.mode;',
'const shared = { mode: "initial" }; const alias = (shared satisfies { mode: string }) as { mode: string }; delete alias.mode; return shared.mode;',
"const shared = [1]; const alias = shared satisfies number[]; alias[0]++; return shared[0];",
])("keeps the mutated value unknown: %s", (body) => {
expect(resultValue(body)?.status).toBe("unknown");
});
it("retains the untouched property through a satisfies alias", () => {
expect(
resultValue(`
const shared = { mode: "initial", token: "TOKEN" };
const alias = shared satisfies { mode: string; token: string };
alias.mode = "updated";
return shared.token;
`),
).toEqual({ status: "literal", value: "TOKEN" });
});
});
@@ -125,6 +125,7 @@ const referencedValues = (
if (
t.isTSAsExpression(node) ||
t.isTSTypeAssertion(node) ||
t.isTSSatisfiesExpression(node) ||
t.isTSNonNullExpression(node)
)
return [{ node: node.expression, path }];