From 0ace143771196eec8d7fcf0df506203999172a08 Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:41:38 +0800 Subject: [PATCH 01/23] fix(browser): retain inventory changes with unavailable shapes --- docs/browser-observation.md | 7 + src/domain/webCaptureDiff.network.test.ts | 235 ++++++++++++++++++ src/domain/webCaptureDiff.ts | 29 ++- .../tools/passiveNetworkInventory.test.ts | 88 +++++++ tests/fixtures/passiveNetworkCapture.ts | 104 ++++++++ 5 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 src/domain/webCaptureDiff.network.test.ts create mode 100644 tests/acceptance/tools/passiveNetworkInventory.test.ts create mode 100644 tests/fixtures/passiveNetworkCapture.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 9479f4b3c65..6b457d1c153 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -270,6 +270,13 @@ REA retains local observation data and removes only URL userinfo credentials, ex - Accessibility structure and roles are retained by default, while names and descriptions require `include_accessibility_text: true`. - Network observations retain method, status, MIME type, size, type, initiator stack location, and complete URLs with userinfo credentials removed. Redirects reported by CDP are attached as ordered `redirects` on the one request ID, with the prior request URL, CDP response URL, method/type, response status/MIME/encoded length, request timestamp, and the next `requestWillBeSent` event timestamp. CDP delivers `redirectResponse` with that next request event, so the latter is an event boundary rather than an exact response timestamp. Redirect response headers and bodies are discarded; hops outside the selected origins are discarded with the request chain. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained. - Request/response bodies are not requested or parsed by default. When selected, only allowed-origin JSON media types are read, converted immediately to complete value-free typed paths, types and observation counts, and discarded. Each path segment is either `{ "kind": "property", "name": "raw property name" }` or `{ "kind": "array-element" }`; array-element types include primitives and nested arrays. Literal `*` properties remain distinct from array elements. `Network.getResponseBody` is never sent unless JSON body shape capture is selected. Comparison uses body shapes only when both captures selected them, and compares request/response shapes only where both observations provide those facts. Unavailable or truncated selected coverage makes equivalence unknown; observed differences still report changed. + For normalized passive inputs with complete request inventories on both sides, + request additions and removals remain observable even when selected body shapes, + response metadata, or WebSocket coverage are unavailable. HTTP request and + WebSocket inventory coverage are evaluated separately; complete HTTP coverage + cannot prove a missing WebSocket connection. Limited request inventory still + prevents request-absence claims. This comparison rule does not make live CDP's + attach-limited inventory complete or establish body-shape equality. - Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded. - WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. diff --git a/src/domain/webCaptureDiff.network.test.ts b/src/domain/webCaptureDiff.network.test.ts new file mode 100644 index 00000000000..09e387f5224 --- /dev/null +++ b/src/domain/webCaptureDiff.network.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; + +import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js"; +import { + browserCaptureComparisonInputSchema, + compareBrowserCaptures, +} from "./browserCaptureComparison.js"; +import type { WebPageInspection } from "./browserObservationSchemas.js"; +import { webCaptureDiffSchema } from "./webCaptureDiffSchemas.js"; + +const compare = (before: WebPageInspection, after: WebPageInspection) => + webCaptureDiffSchema.parse( + compareBrowserCaptures( + browserCaptureComparisonInputSchema.parse({ + before: { inspection: before }, + after: { inspection: after }, + }), + ), + ).dimensions.network; + +describe("passive network inventory changes", () => { + it.each(["unavailable", "partial", "truncated"] as const)( + "retains complete request additions/removals with %s selected shapes", + (coverage) => { + const before = passiveNetworkCapture(["/a"]); + const after = passiveNetworkCapture(["/a", "/b"]); + for (const capture of [before, after]) { + if (coverage === "truncated") + capture.completeness.truncated_sections.push("json_body_shapes"); + else capture.completeness.unavailable_sections.push("json_body_shapes"); + for (const request of capture.network.requests) { + const shape = { + root_type: "object" as const, + node_count: 1, + max_depth_observed: 0, + properties: [], + }; + if (coverage !== "unavailable") request.body_shapes.request = shape; + if (coverage === "truncated") request.body_shapes.response = shape; + request.body_shapes.status = + coverage === "truncated" ? "included" : coverage; + } + } + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) + expect(compare(left, right)).toEqual({ + status: "changed", + total_changes: 1, + changes: [{ identity: expect.stringMatching(/^net_/u), change }], + reason: null, + }); + expect(compare(after, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }, + ); + + it("keeps request absence proof independent of WebSocket inventory and response metadata", () => { + const before = passiveNetworkCapture([]); + const after = passiveNetworkCapture(["/added"]); + for (const capture of [before, after]) { + capture.completeness.attach_limited_sections.push( + "websocket_connections", + "websocket_frames", + ); + for (const request of capture.network.requests) { + request.status = null; + request.mime_type = null; + request.encoded_data_length = null; + } + } + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change }], + }); + expect(compare(after, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }); +}); + +describe("passive network coverage uncertainty", () => { + it.each([ + "attach_limited_sections", + "unavailable_sections", + "truncated_sections", + "policy_filtered_sections", + ] as const)( + "does not infer request absence when its inventory is %s", + (section) => { + const before = passiveNetworkCapture(); + const after = passiveNetworkCapture(["/a", "/b"]); + after.completeness[section].push("network_requests"); + for (const [left, right] of [ + [before, after], + [after, before], + ]) + expect(compare(left!, right!)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + after.network.requests[0]!.status = 503; + expect(compare(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "modified" }], + }); + }, + ); + + it("does not promote unavailable shapes to a modification and preserves unselected-shape behavior", () => { + const before = passiveNetworkCapture(); + const after = structuredClone(before); + after.network.requests[0]!.body_shapes = { + status: "included", + request: { + root_type: "object", + node_count: 1, + max_depth_observed: 0, + properties: [], + }, + response: { + root_type: "string", + node_count: 1, + max_depth_observed: 0, + properties: [], + }, + }; + expect(compare(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + before.completeness.excluded.push({ + section: "json_body_shapes", + reason: "not_approved", + count: null, + }); + expect(compare(before, after)).toMatchObject({ + status: "unchanged", + total_changes: 0, + }); + }); +}); + +describe("independent WebSocket inventory evidence", () => { + it("does not use complete HTTP inventory to infer an unobserved WebSocket connection", () => { + const before = passiveNetworkCapture(); + const after = structuredClone(before); + after.network.websocket_connections.push({ + request_id: "ws", + url: "wss://app.example.test/live", + events: [], + }); + before.completeness.attach_limited_sections.push("websocket_connections"); + expect(compare(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + expect(compare(after, before)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + after.network.requests.push({ + ...passiveNetworkCapture(["/b"]).network.requests[0]!, + request_id: "request-b", + }); + expect(compare(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ identity: expect.stringMatching(/^net_/u), change: "added" }], + }); + }); + + it("retains complete WebSocket membership changes when HTTP inventory is limited", () => { + const before = passiveNetworkCapture(); + before.completeness.attach_limited_sections.push("network_requests"); + const after = structuredClone(before); + after.network.websocket_connections.push({ + request_id: "ws", + url: "wss://app.example.test/live", + events: [], + }); + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ identity: expect.stringMatching(/^ws_/u), change }], + }); + }); + + it("does not turn missing WebSocket payload facts into membership changes", () => { + const before = passiveNetworkCapture(); + before.network.websocket_connections.push({ + request_id: "ws", + url: "wss://app.example.test/live", + events: [ + { + direction: "sent", + opcode: 1, + payload_bytes: 4, + payload_shape: { format: "text", json_shape: null }, + }, + ], + }); + const after = structuredClone(before); + const event = after.network.websocket_connections[0]!.events[0]!; + event.payload_bytes = null; + event.payload_shape = null; + expect(compare(before, after)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + after.network.requests.push({ + ...passiveNetworkCapture(["/b"]).network.requests[0]!, + request_id: "request-b", + }); + expect(compare(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ identity: expect.stringMatching(/^net_/u), change: "added" }], + }); + }); +}); diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index ba23228b11e..b28c8e63913 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -70,11 +70,15 @@ const compareDimension = ( after: ReadonlyMap, complete: boolean, reason: string, - inventoryComplete = complete, + inventoryComplete: boolean | ((identity: string) => boolean) = complete, ): Dimension => { // Membership evidence can be complete even when selected content is unknown. const all = compareIdentities(before, after).filter( - ({ change }) => inventoryComplete || change === "modified", + ({ identity, change }) => + change === "modified" || + (typeof inventoryComplete === "boolean" + ? inventoryComplete + : inventoryComplete(identity)), ); if (all.length > 0) return { @@ -274,6 +278,12 @@ const networkDimension = ( before: WebPageInspection, after: WebPageInspection, ): Dimension => { + const requestsComplete = [before, after].every((inspection) => + sectionsComplete(inspection, ["network_requests"]), + ); + const webSocketInventoryComplete = [before, after].every((inspection) => + sectionsComplete(inspection, ["websocket_connections", "websocket_frames"]), + ); const responseFields = sharedNetworkResponseFields(before, after); const responseFieldsComplete = [...responseFields.values()].every( (fields) => fields.size === networkResponseFields.length, @@ -349,17 +359,18 @@ const networkDimension = ( ...webSocketMetadataMap(afterWebSockets), ...afterWebSocketPayload, ]), - [before, after].every((inspection) => - sectionsComplete(inspection, [ - "network_requests", - "websocket_connections", - "websocket_frames", - ]), - ) && + requestsComplete && + webSocketInventoryComplete && shapesComplete && webSocketPayloadBytesComplete && webSocketShapesComplete, "Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.", + // HTTP and WebSocket identities have independent inventory coverage. Shape + // selection can prevent equality without erasing proven membership changes. + (identity) => + identity.startsWith("net_") + ? requestsComplete + : identity.startsWith("ws_") && webSocketInventoryComplete, ); return compared.status === "unchanged" && !responseFieldsComplete ? { diff --git a/tests/acceptance/tools/passiveNetworkInventory.test.ts b/tests/acceptance/tools/passiveNetworkInventory.test.ts new file mode 100644 index 00000000000..bc8b6488c6b --- /dev/null +++ b/tests/acceptance/tools/passiveNetworkInventory.test.ts @@ -0,0 +1,88 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { webCaptureDiffSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { passiveNetworkCapture } from "../../fixtures/passiveNetworkCapture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "%s retains passive request inventory changes independently of unknown content", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-passive-inventory-"); + const paths = [ + join(root, "before.json"), + join(root, "after.json"), + ] as const; + const before = passiveNetworkCapture(); + const after = passiveNetworkCapture(["/a", "/b"]); + const incomplete = structuredClone(before); + incomplete.completeness.attach_limited_sections.push("network_requests"); + const wsBefore = structuredClone(before); + const wsAfter = structuredClone(after); + for (const capture of [wsBefore, wsAfter]) + capture.completeness.attach_limited_sections.push( + "websocket_connections", + "websocket_frames", + ); + wsAfter.network.websocket_connections.push({ + request_id: "ws", + url: "wss://app.example.test/live", + events: [], + }); + const mcp = + surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; + for (const [name, left, right, change] of [ + ["added", before, after, "added"], + ["removed", after, before, "removed"], + ["equal unavailable", before, before, null], + ["incomplete inventory", incomplete, after, null], + ["limited WebSocket inventory", wsBefore, wsAfter, "added"], + ] as const) { + const input = { + before: { inspection: left }, + after: { inspection: right }, + }; + const serialized = [ + JSON.stringify(input.before), + JSON.stringify(input.after), + ]; + let document: unknown; + if (mcp === undefined) { + await Promise.all( + paths.map((path, index) => writeFile(path, serialized[index]!)), + ); + const response = await cli.run({ + arguments: ["compare-web-captures", ...paths, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + expect( + await Promise.all(paths.map((path) => readFile(path, "utf8"))), + ).toEqual(serialized); + } else { + const response = await mcp.call("compare_web_captures", input); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const evidence = parseEvidence(document); + expect(evidence.operation).toBe("compare_web_captures"); + const result = webCaptureDiffSchema.parse(evidence.normalized_result); + expect(result.dimensions.network, name).toMatchObject({ + status: change === null ? "unknown" : "changed", + total_changes: change === null ? 0 : 1, + changes: + change === null + ? [] + : [{ identity: expect.stringMatching(/^net_/u), change }], + }); + expect(result.overall_status).toBe( + change === null ? "unknown" : "changed", + ); + } + }, +); diff --git a/tests/fixtures/passiveNetworkCapture.ts b/tests/fixtures/passiveNetworkCapture.ts new file mode 100644 index 00000000000..8192751bc26 --- /dev/null +++ b/tests/fixtures/passiveNetworkCapture.ts @@ -0,0 +1,104 @@ +import { webPageInspectionSchema } from "../../src/domain/browserObservationSchemas.js"; + +/** Owned normalized comparison input, not a claim about live CDP inventory coverage. */ +export const passiveNetworkCapture = (paths: readonly string[] = ["/a"]) => { + const origin = "https://app.example.test"; + const timestamp = "2026-10-14T00:00:00.000Z"; + return webPageInspectionSchema.parse({ + browser: { + product: "normalized-fixture", + protocol_version: "1.3", + revision: "fixture", + user_agent: "fixture", + js_version: "fixture", + }, + target: { + target_id: "page", + type: "page", + title: "Fixture", + url: origin, + origin, + attached: false, + }, + capture_window: { + started_at: timestamp, + ended_at: timestamp, + observation_ms: 0, + }, + completeness: { + status: "complete_within_window", + conditions: ["complete_within_window"], + policy_filtered_sections: [], + attach_limited_sections: [], + truncated_sections: [], + unavailable_sections: [], + excluded: [], + dropped_events: { + scripts: 0, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + total: 0, + }, + }, + network: { + requests: paths.map((path, index) => ({ + request_id: `request-${String(index)}`, + url: `${origin}${path}`, + origin, + method: "GET", + resource_type: "Document", + status: 200, + mime_type: "text/html", + encoded_data_length: 10, + initiator: { type: "parser", url: null, line: null, column: null }, + body_shapes: { status: "unavailable", request: null, response: null }, + })), + websocket_connections: [], + coverage_started_at: timestamp, + prior_activity_available: false, + }, + frames: [], + dom: { total_nodes: 0, nodes: [] }, + accessibility: { + total_nodes: 0, + nodes: [], + text_capture: { + status: "not_approved", + retained_bytes: 0, + excluded_fields: 0, + }, + }, + scripts: { total: 0, items: [] }, + resources: [], + workers: [], + console: { + events: [], + coverage_started_at: timestamp, + prior_activity_available: false, + }, + metadata: { + responses: [], + dom_urls: [], + agent_hints: [], + excluded_dom_urls: 0, + headers_allowlisted: true, + }, + storage: { + origin, + usage_bytes: null, + quota_bytes: null, + local_storage_keys: [], + session_storage_keys: [], + indexed_db_names: [], + cache_names: [], + values_redacted: true, + }, + limitations: [ + "Synthetic normalized inventory for offline comparison tests; not recorded from a live browser.", + ], + }); +}; From 44504630193253978d1de919bcbb0645652c7b32 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:50:50 +0000 Subject: [PATCH 02/23] fix(javascript): restore read-site ambient aliases and string config reads Main fails five source tests after 04672f2b9 and d4bd65c53. - Ambient write resolution used the caller's write site for every alias read it reached, so `alias = alias.globalThis` resolved its own self-read only to the reassignment and lost the original `globalThis` identity. Reads reached through an initializer now select initializers at their own position; the written target keeps the caller's write site. - Process escape tracking recorded `Number(process.env.PORT)` and `Number(process.argv[2])` as escapes of the read slot, which dropped the configuration read inside functions. Node stores env values and argv entries as strings, so passing one exposes no mutable object; replacing an entry still records its own write or receiver escape. - The dynamic isolated-world test now expects the direct observation that the proven Webpack bootstrap resolution produces. - The native IDA admission test now matches d4bd65c53: attached profiles report the bound document and still refuse address resolution. Co-Authored-By: Claude Opus 5.5 --- .../electronWrappedCallPaths.test.ts | 12 +++++++-- .../javascriptSemanticAmbientWrites.ts | 27 ++++++++++++------- .../javascriptSemanticMemberMutations.ts | 10 +++++++ src/ida/IdaNativeProtocol.test.ts | 22 +++++++-------- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 6d30a10f527..b92aa990f64 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -147,7 +147,9 @@ describe("wrapped contextBridge exposure methods", () => { `const worldId = selectWorld(); electron.contextBridge.exposeInIsolatedWorld(worldId, "isolatedApi", {ping: true});`, ), ); - expect(result.electron.context_bridge_apis).toContainEqual( + // The proven Webpack bootstrap resolves `load(2)` directly, so the complete + // direct observation is retained instead of a wrapper substitution. + expect(result.electron.context_bridge_apis).toEqual([ expect.objectContaining({ world: "isolated", world_id: { @@ -156,9 +158,15 @@ describe("wrapped contextBridge exposure methods", () => { expression: "worldId", }, api_key: "isolatedApi", + api_status: "object-literal", members: ["ping"], - resolution_basis: "wrapper-substitution", + unknown_members: 0, + module_key: "9", + module_runtime: "webpack-bootstrap@0", }), + ]); + expect(result.electron.context_bridge_apis[0]).not.toHaveProperty( + "resolution_basis", ); expect(result.parse_status).toBe("partial"); }); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index f7c4a8e7aee..e582f07a850 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -56,9 +56,13 @@ const createAmbientPathResolver = ( const prior = possibleWrites ? undefined : cached.get(node); if (prior !== undefined) return prior; const result = new Map(); - const pending: { node: t.Node; suffix: AmbientProjection }[] = [ - { node, suffix: [] }, - ]; + // The caller's write site selects initializers for the written target. + // Reads reached through an initializer occur at their own position. + const pending: { + node: t.Node; + suffix: AmbientProjection; + readsAtSelf?: true; + }[] = [{ node, suffix: [] }]; // Join projections at their producing expression, rather than enumerating // alias histories. Each key can only widen to a wildcard, and conflicting // depths widen to unknown, so diamonds and cycles have finite state. @@ -105,7 +109,11 @@ const createAmbientPathResolver = ( // A widened projection is a possible write, never proof of an intrinsic. if (suffix === undefined && !possibleWrites) continue; const continueWith = (node: t.Node, path: AmbientProjection = suffix) => - pending.push({ node, suffix: path }); + pending.push({ + node, + suffix: path, + ...(current.readsAtSelf && { readsAtSelf: true }), + }); if ( t.isMetaProperty(root) && root.meta.name === "import" && @@ -126,20 +134,19 @@ const createAmbientPathResolver = ( const binding = resolveSemanticBindingState(state, root, root.name); if (binding === undefined) continue; const stable = semanticStableInitializer(binding, state); + const readAt = current.readsAtSelf ? root : capture; const initializers = possibleWrites - ? capture === undefined + ? readAt === undefined ? binding.initializers - : semanticMutationInitializers( - binding, - capture, - state.parentsByNode, - ).initializers + : semanticMutationInitializers(binding, readAt, state.parentsByNode) + .initializers : stable === undefined ? [] : [stable]; for (const initializer of initializers) pending.push({ node: initializer.node, + readsAtSelf: true, suffix: suffix === undefined ? undefined diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index f3d7063b76c..34666e65c73 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -69,6 +69,16 @@ export function* collectSemanticMemberMutationsSteps( node: t.Node | undefined, ): void => { const identity = semanticRequireExportsIdentity(specifier, path); + // Node stores process.env values and process.argv entries as strings, so + // passing one to a call exposes no object that the callee could mutate. + // Replacing an entry records its own write or receiver escape. + if ( + effect === "escape" && + identity.specifier === "process" && + identity.path.length === 2 && + (identity.path[0] === "env" || identity.path[0] === "argv") + ) + return; const key = JSON.stringify([ "required", identity.specifier, diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 466a09ad569..a1ba9fc0ce4 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -779,18 +779,18 @@ describe("native IDA attached operation admission", () => { await client.close(); }); - it("does not admit the headless-only containment and document operations", async () => { - const { client, producer } = await fixture(); - for (const [operation, parameters] of [ - ["resolve_containing_procedure", { address: "0x1000" }], - ["list_documents", {}], - ] as const) { - const result = await client.execute(operation, parameters); + it("reports the bound document but not headless-only address resolution", async () => { + const { client, producer, target } = await fixture(); + const containing = await client.execute("resolve_containing_procedure", { + address: "0x1000", + }); + expect(containing.ok).toBe(false); + if (!containing.ok) + expect(containing.error._tag).toBe("AnalysisCapabilityUnavailableError"); - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); - } + const documents = await client.execute("list_documents", {}); + if (!documents.ok) throw documents.error; + expect(documents.value.result).toEqual([target.path]); expect( producer.calls.some(({ name }) => name === "lookup_funcs"), "nothing is dispatched upstream", From d625dfcccfd2df46a0940c91e46338d83463e1d0 Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:53:28 +0800 Subject: [PATCH 03/23] fix(browser): preserve unknown request identity in inventory diffs --- docs/browser-observation.md | 4 + src/domain/webCaptureDiff.network.test.ts | 125 ++++++++++++++++++ src/domain/webCaptureDiff.ts | 57 +++++++- .../tools/passiveNetworkInventory.test.ts | 26 ++++ 4 files changed, 210 insertions(+), 2 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 6b457d1c153..96a428b13f5 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -277,6 +277,10 @@ REA retains local observation data and removes only URL userinfo credentials, ex cannot prove a missing WebSocket connection. Limited request inventory still prevents request-absence claims. This comparison rule does not make live CDP's attach-limited inventory complete or establish body-shape equality. + An unavailable HTTP resource type also leaves request identity incomplete: + observations with the same method and URL remain possible matches across types, + and identical unknown types cannot establish complete equality. Different + methods/URLs and complete empty peer inventories still support absence claims. - Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded. - WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. diff --git a/src/domain/webCaptureDiff.network.test.ts b/src/domain/webCaptureDiff.network.test.ts index 09e387f5224..9f11ff85289 100644 --- a/src/domain/webCaptureDiff.network.test.ts +++ b/src/domain/webCaptureDiff.network.test.ts @@ -233,3 +233,128 @@ describe("independent WebSocket inventory evidence", () => { }); }); }); + +describe("nullable HTTP request identities", () => { + const unselectedShapes = (capture: WebPageInspection): WebPageInspection => { + capture.completeness.excluded.push({ + section: "json_body_shapes", + reason: "not_approved", + count: null, + }); + for (const request of capture.network.requests) + request.body_shapes.status = "not_approved"; + return capture; + }; + + it.each([true, false])( + "keeps known/unknown resource-type matches uncertain (shapes selected=%s)", + (selected) => { + const before = passiveNetworkCapture(); + const after = structuredClone(before); + after.network.requests[0]!.resource_type = null; + if (!selected) + for (const capture of [before, after]) unselectedShapes(capture); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + reason: expect.stringContaining("request identity"), + }); + }, + ); + + it("does not prove equality from two unknown request types", () => { + const capture = unselectedShapes(passiveNetworkCapture()); + capture.network.requests[0]!.resource_type = null; + expect(compare(capture, structuredClone(capture))).toMatchObject({ + status: "unknown", + total_changes: 0, + reason: expect.stringContaining("request identity"), + }); + }); + + it("does not assign an unknown type to one of multiple same-method/URL candidates", () => { + const before = unselectedShapes(passiveNetworkCapture(["/a", "/a"])); + before.network.requests[1]!.resource_type = "Fetch"; + const after = structuredClone(before); + after.network.requests[1]!.resource_type = null; + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }); + + it.each([ + ["/b", "GET"], + ["/a", "POST"], + ])( + "retains a distinct %s %s request beside ambiguous identities", + (path, method) => { + const before = unselectedShapes(passiveNetworkCapture()); + const after = unselectedShapes(passiveNetworkCapture(["/a", path])); + after.network.requests[0]!.resource_type = null; + after.network.requests[1]!.method = method; + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ identity: expect.stringMatching(/^net_/u), change }], + }); + }, + ); + + it("uses a complete empty peer inventory to prove absence despite an unknown type", () => { + const empty = unselectedShapes(passiveNetworkCapture([])); + const observed = unselectedShapes(passiveNetworkCapture()); + observed.network.requests[0]!.resource_type = null; + for (const [left, right, change] of [ + [empty, observed, "added"], + [observed, empty, "removed"], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change }], + }); + expect(compare(empty, empty)).toMatchObject({ + status: "unchanged", + total_changes: 0, + }); + }); + + it("retains definite type changes and independently observed metadata changes", () => { + const before = unselectedShapes(passiveNetworkCapture()); + const changedType = structuredClone(before); + changedType.network.requests[0]!.resource_type = "Fetch"; + expect(compare(before, changedType)).toMatchObject({ + status: "changed", + total_changes: 2, + changes: expect.arrayContaining([ + expect.objectContaining({ change: "added" }), + expect.objectContaining({ change: "removed" }), + ]), + }); + before.network.requests.push({ + ...passiveNetworkCapture(["/b"]).network.requests[0]!, + resource_type: null, + }); + const changedStatus = structuredClone(before); + changedStatus.network.requests[0]!.status = 503; + expect(compare(before, changedStatus)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "modified" }], + }); + }); +}); diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index b28c8e63913..bd7501f2800 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -251,6 +251,55 @@ const networkIdentity = ( ): string => `net_${digestCanonicalValue({ method: request.method, url: request.url, resource_type: request.resource_type })}`; +/** An unknown request type can match any observed type at the same method/URL. */ +const networkIdentityCoverage = ( + before: WebPageInspection, + after: WebPageInspection, +): { + readonly complete: boolean; + readonly unprovenAbsences: ReadonlySet; +} => { + const complete = [before, after].every((inspection) => + inspection.network.requests.every( + ({ resource_type }) => resource_type !== null, + ), + ); + const unprovenAbsences = new Set(); + if (complete) return { complete, unprovenAbsences }; + type Request = WebPageInspection["network"]["requests"][number]; + const matchKey = (request: Request): string => + JSON.stringify([request.method, request.url]); + const unknownTypes = ( + inspection: WebPageInspection, + ): ReadonlyMap => { + const groups = new Map(); + for (const request of inspection.network.requests) { + const key = matchKey(request); + groups.set( + key, + groups.get(key) === true || request.resource_type === null, + ); + } + return groups; + }; + const retainPotentialMatches = ( + inspection: WebPageInspection, + peer: ReadonlyMap, + ): void => { + for (const request of inspection.network.requests) { + const peerUnknown = peer.get(matchKey(request)); + if ( + peerUnknown === true || + (peerUnknown === false && request.resource_type === null) + ) + unprovenAbsences.add(networkIdentity(request)); + } + }; + retainPotentialMatches(before, unknownTypes(after)); + retainPotentialMatches(after, unknownTypes(before)); + return { complete, unprovenAbsences }; +}; + const networkResponseFields = [ "status", "mime_type", @@ -281,6 +330,7 @@ const networkDimension = ( const requestsComplete = [before, after].every((inspection) => sectionsComplete(inspection, ["network_requests"]), ); + const requestIdentities = networkIdentityCoverage(before, after); const webSocketInventoryComplete = [before, after].every((inspection) => sectionsComplete(inspection, ["websocket_connections", "websocket_frames"]), ); @@ -360,16 +410,19 @@ const networkDimension = ( ...afterWebSocketPayload, ]), requestsComplete && + requestIdentities.complete && webSocketInventoryComplete && shapesComplete && webSocketPayloadBytesComplete && webSocketShapesComplete, - "Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.", + requestIdentities.complete + ? "Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete." + : "HTTP request identity is incomplete because a resource type is unavailable; absence of a potentially matching request and complete network equality cannot be established.", // HTTP and WebSocket identities have independent inventory coverage. Shape // selection can prevent equality without erasing proven membership changes. (identity) => identity.startsWith("net_") - ? requestsComplete + ? requestsComplete && !requestIdentities.unprovenAbsences.has(identity) : identity.startsWith("ws_") && webSocketInventoryComplete, ); return compared.status === "unchanged" && !responseFieldsComplete diff --git a/tests/acceptance/tools/passiveNetworkInventory.test.ts b/tests/acceptance/tools/passiveNetworkInventory.test.ts index bc8b6488c6b..8e17be6867b 100644 --- a/tests/acceptance/tools/passiveNetworkInventory.test.ts +++ b/tests/acceptance/tools/passiveNetworkInventory.test.ts @@ -33,6 +33,18 @@ cliTest.for(["CLI", "stdio MCP"] as const)( url: "wss://app.example.test/live", events: [], }); + const unknownIdentity = structuredClone(before); + unknownIdentity.network.requests[0]!.resource_type = null; + const unknownIdentityAdded = structuredClone(after); + unknownIdentityAdded.network.requests[0]!.resource_type = null; + const unselectedUnknownIdentity = structuredClone(unknownIdentity); + unselectedUnknownIdentity.completeness.excluded.push({ + section: "json_body_shapes", + reason: "not_approved", + count: null, + }); + unselectedUnknownIdentity.network.requests[0]!.body_shapes.status = + "not_approved"; const mcp = surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; for (const [name, left, right, change] of [ @@ -41,6 +53,20 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ["equal unavailable", before, before, null], ["incomplete inventory", incomplete, after, null], ["limited WebSocket inventory", wsBefore, wsAfter, "added"], + ["unknown request identity", before, unknownIdentity, null], + ["unknown request identity reversed", unknownIdentity, before, null], + [ + "unknown identity without selected shapes", + unselectedUnknownIdentity, + unselectedUnknownIdentity, + null, + ], + [ + "different URL beside unknown identity", + before, + unknownIdentityAdded, + "added", + ], ] as const) { const input = { before: { inspection: left }, From 828ccc36b65c0ea373cddc227998b5b9bcef7b28 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:06:14 +0000 Subject: [PATCH 04/23] test: cover config value escapes and source-map edge pairing - Cover configuration reads after passing env/argv values to a call, and keep reads unknown after replacing an argv entry directly, through an alias, or after a container escape. - b58adafa4 added `resolution_status` to source-map edges. Expect it, and pair each alias edge with the script that declared it instead of relying on content-derived edge ID order. Co-Authored-By: Claude Opus 5.5 --- ...ptSemanticAnalysis.native_bindings.test.ts | 40 +++++++++++++++++++ .../javascriptSourceMapLocations.test.ts | 34 +++++++++++----- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts index 784a5a5b6f1..30585330148 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts @@ -206,3 +206,43 @@ describe("JavaScript semantic analysis: child-process signals", () => { ]); }); }); + +describe("JavaScript semantic analysis: configuration value escapes", () => { + const configurationReads = (body: string) => + analyzeJavaScriptSemantics(`function run() { ${body} }`) + .configurationOperations.filter(({ kind }) => kind !== "default") + .map(({ kind, key }) => [kind, key]); + + it.each([ + [ + "an environment value", + "return Number(process.env.PORT);", + "environment", + "PORT", + ], + ["an argv entry", "return Number(process.argv[2]);", "argv", "2"], + ])("keeps a read after passing %s to a call", (_name, body, kind, key) => { + expect(configurationReads(body)).toEqual([[kind, key]]); + }); + + it.each([ + [ + "a direct argv entry", + "process.argv[2] = {}; return Number(process.argv[2]);", + ], + [ + "an aliased argv entry", + "const argv = process.argv; argv[2] = {}; return Number(process.argv[2]);", + ], + [ + "the argv container", + "mutate(process.argv); return Number(process.argv[2]);", + ], + [ + "the env container", + "mutate(process.env); return Number(process.env.PORT);", + ], + ])("leaves a read unknown after replacing %s", (_name, body) => { + expect(configurationReads(body)).toEqual([]); + }); +}); diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index 69a5af66215..d9d4c8ffb51 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -134,7 +134,9 @@ it("associates each source-map URL with its edge when aliases share one physical ); expect(mapEdges).toHaveLength(2); expect(mapEdges[0]?.target_node_id).toBe(mapEdges[1]?.target_node_id); - expect( + // Edge order follows content-derived edge IDs, so pair each edge with the + // script whose comment declared it. + const declaredBySource = new Map( mapEdges.map((edge) => { const target = nodes.get(edge.target_node_id); const observation = target?.observations.find( @@ -142,15 +144,28 @@ it("associates each source-map URL with its edge when aliases share one physical properties.declared_url_sha256 === edge.properties.declared_url_sha256, ); - return { - declared_url: observation?.properties.declared_url, - resolved_path: edge.properties.resolved_path, - }; + const location = edge.evidence.location; + return [ + location.available && location.value.kind === "source-range" + ? location.value.source + : null, + { + declared_url: observation?.properties.declared_url, + resolved_path: edge.properties.resolved_path, + }, + ]; }), - ).toEqual([ - { declared_url: "app.js.map", resolved_path: "dist/app.js.map" }, - { declared_url: "./app.js.map", resolved_path: "dist/app.js.map" }, - ]); + ); + expect(Object.fromEntries(declaredBySource)).toEqual({ + "dist/one.js": { + declared_url: "app.js.map", + resolved_path: "dist/app.js.map", + }, + "dist/two.js": { + declared_url: "./app.js.map", + resolved_path: "dist/app.js.map", + }, + }); }); it("resolves a bare source-map file name and worker URL next to the script", async () => { @@ -191,6 +206,7 @@ it("resolves a bare source-map file name and worker URL next to the script", asy .update("app.js.map") .digest("hex"), resolved_path: "dist/app.js.map", + resolution_status: "resolved", }); expect( nodes From b911d21f7e9c39dd3930789e8eae3594efeb0c82 Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:14:00 +0800 Subject: [PATCH 05/23] fix(browser): include dropped events in network inventory coverage --- docs/browser-observation.md | 5 + src/domain/webCaptureDiff.network.test.ts | 172 ++++++++++++++++-- src/domain/webCaptureDiff.ts | 23 ++- .../tools/passiveNetworkInventory.test.ts | 45 ++++- tests/fixtures/passiveNetworkCapture.ts | 25 +++ 5 files changed, 254 insertions(+), 16 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 96a428b13f5..76a0d2cd3ac 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -281,6 +281,11 @@ REA retains local observation data and removes only URL userinfo credentials, ex observations with the same method and URL remain possible matches across types, and identical unknown types cannot establish complete equality. Different methods/URLs and complete empty peer inventories still support absence claims. + Relevant dropped-event counts also limit inventory completeness even when no + section is listed as truncated: HTTP uses `network_requests`, while WebSocket + uses `websocket_connections` and `websocket_frames`. Drops in other event kinds + and the aggregate total do not by themselves limit HTTP inventory. Proven + modifications remain reportable despite incomplete inventory. - Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded. - WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. diff --git a/src/domain/webCaptureDiff.network.test.ts b/src/domain/webCaptureDiff.network.test.ts index 9f11ff85289..dbff353a636 100644 --- a/src/domain/webCaptureDiff.network.test.ts +++ b/src/domain/webCaptureDiff.network.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js"; +import { + passiveNetworkCapture, + withPassiveDroppedEvents, +} from "../../tests/fixtures/passiveNetworkCapture.js"; import { browserCaptureComparisonInputSchema, compareBrowserCaptures, @@ -18,6 +21,17 @@ const compare = (before: WebPageInspection, after: WebPageInspection) => ), ).dimensions.network; +const unselectedShapes = (capture: WebPageInspection): WebPageInspection => { + capture.completeness.excluded.push({ + section: "json_body_shapes", + reason: "not_approved", + count: null, + }); + for (const request of capture.network.requests) + request.body_shapes.status = "not_approved"; + return capture; +}; + describe("passive network inventory changes", () => { it.each(["unavailable", "partial", "truncated"] as const)( "retains complete request additions/removals with %s selected shapes", @@ -235,17 +249,6 @@ describe("independent WebSocket inventory evidence", () => { }); describe("nullable HTTP request identities", () => { - const unselectedShapes = (capture: WebPageInspection): WebPageInspection => { - capture.completeness.excluded.push({ - section: "json_body_shapes", - reason: "not_approved", - count: null, - }); - for (const request of capture.network.requests) - request.body_shapes.status = "not_approved"; - return capture; - }; - it.each([true, false])( "keeps known/unknown resource-type matches uncertain (shapes selected=%s)", (selected) => { @@ -358,3 +361,148 @@ describe("nullable HTTP request identities", () => { }); }); }); + +describe("passive dropped-event inventory coverage", () => { + it("does not infer request absence from a capture with independently counted drops", () => { + const before = withPassiveDroppedEvents(passiveNetworkCapture(), { + network_requests: 1, + }); + const after = passiveNetworkCapture(["/a", "/b"]); + expect(before.completeness).toMatchObject({ + status: "truncated", + truncated_sections: [], + unavailable_sections: [], + attach_limited_sections: [], + policy_filtered_sections: [], + dropped_events: { network_requests: 1, total: 1 }, + }); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + const changed = structuredClone(before); + changed.network.requests[0]!.status = 503; + expect(compare(before, changed)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "modified" }], + }); + }); + + it.each([ + "network_requests", + "websocket_connections", + "websocket_frames", + ] as const)( + "cannot prove network equality after dropped %s even when shapes are unselected", + (kind) => { + const before = withPassiveDroppedEvents( + unselectedShapes(passiveNetworkCapture()), + { [kind]: 1 }, + ); + expect(before.completeness.truncated_sections).toEqual([]); + expect(compare(before, structuredClone(before))).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + }, + ); + + it.each(["websocket_connections", "websocket_frames"] as const)( + "keeps %s drops separate from HTTP inventory", + (kind) => { + const before = withPassiveDroppedEvents(passiveNetworkCapture(), { + [kind]: 1, + }); + const after = structuredClone(before); + after.network.websocket_connections.push({ + request_id: "ws", + url: "wss://app.example.test/live", + events: [], + }); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + }); + after.network.requests.push({ + ...passiveNetworkCapture(["/b"]).network.requests[0]!, + request_id: "request-b", + }); + for (const [left, right, change] of [ + [before, after, "added"], + [after, before, "removed"], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ identity: expect.stringMatching(/^net_/u), change }], + }); + }, + ); + + it("retains complete WebSocket membership despite HTTP drops", () => { + const before = withPassiveDroppedEvents(passiveNetworkCapture(), { + network_requests: 1, + }); + const after = structuredClone(before); + after.network.websocket_connections.push({ + request_id: "ws", + url: "wss://app.example.test/live", + events: [], + }); + expect(compare(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ identity: expect.stringMatching(/^ws_/u), change: "added" }], + }); + expect(compare(after, before)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [ + { identity: expect.stringMatching(/^ws_/u), change: "removed" }, + ], + }); + }); + + it.each([ + "console_events", + "scripts", + "timeline_events", + "webmcp_tools", + ] as const)( + "does not use the total or unrelated %s drops as HTTP absence limits", + (kind) => { + const before = withPassiveDroppedEvents( + unselectedShapes(passiveNetworkCapture()), + { [kind]: 2 }, + ); + expect(before.completeness).toMatchObject({ + status: "truncated", + dropped_events: { total: 2 }, + }); + expect(compare(before, structuredClone(before))).toMatchObject({ + status: "unchanged", + total_changes: 0, + }); + const after = unselectedShapes(passiveNetworkCapture(["/a", "/b"])); + expect(compare(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "added" }], + }); + expect(compare(after, before)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "removed" }], + }); + }, + ); +}); diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index bd7501f2800..37aa5806313 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -323,16 +323,33 @@ const sharedNetworkResponseFields = ( return shared; }; +/** Section lists and per-kind dropped counts independently limit absence evidence. */ +const networkInventoryComplete = ( + inspection: WebPageInspection, + sections: readonly ( + | "network_requests" + | "websocket_connections" + | "websocket_frames" + )[], +): boolean => + sectionsComplete(inspection, sections) && + sections.every( + (section) => inspection.completeness.dropped_events[section] === 0, + ); + const networkDimension = ( before: WebPageInspection, after: WebPageInspection, ): Dimension => { const requestsComplete = [before, after].every((inspection) => - sectionsComplete(inspection, ["network_requests"]), + networkInventoryComplete(inspection, ["network_requests"]), ); const requestIdentities = networkIdentityCoverage(before, after); const webSocketInventoryComplete = [before, after].every((inspection) => - sectionsComplete(inspection, ["websocket_connections", "websocket_frames"]), + networkInventoryComplete(inspection, [ + "websocket_connections", + "websocket_frames", + ]), ); const responseFields = sharedNetworkResponseFields(before, after); const responseFieldsComplete = [...responseFields.values()].every( @@ -416,7 +433,7 @@ const networkDimension = ( webSocketPayloadBytesComplete && webSocketShapesComplete, requestIdentities.complete - ? "Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete." + ? "Network capture or selected HTTP/WebSocket payload-shape coverage is limited, unavailable, incomplete, or includes dropped network events." : "HTTP request identity is incomplete because a resource type is unavailable; absence of a potentially matching request and complete network equality cannot be established.", // HTTP and WebSocket identities have independent inventory coverage. Shape // selection can prevent equality without erasing proven membership changes. diff --git a/tests/acceptance/tools/passiveNetworkInventory.test.ts b/tests/acceptance/tools/passiveNetworkInventory.test.ts index 8e17be6867b..bef20797dbe 100644 --- a/tests/acceptance/tools/passiveNetworkInventory.test.ts +++ b/tests/acceptance/tools/passiveNetworkInventory.test.ts @@ -5,7 +5,10 @@ import { expect } from "vitest"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { webCaptureDiffSchema } from "../../../src/domain/webCaptureDiffSchemas.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; -import { passiveNetworkCapture } from "../../fixtures/passiveNetworkCapture.js"; +import { + passiveNetworkCapture, + withPassiveDroppedEvents, +} from "../../fixtures/passiveNetworkCapture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -45,6 +48,26 @@ cliTest.for(["CLI", "stdio MCP"] as const)( }); unselectedUnknownIdentity.network.requests[0]!.body_shapes.status = "not_approved"; + const droppedRequest = withPassiveDroppedEvents(before, { + network_requests: 1, + }); + const droppedWebSocket = withPassiveDroppedEvents(before, { + websocket_connections: 1, + websocket_frames: 1, + }); + const unrelatedDrop = withPassiveDroppedEvents(before, { + console_events: 1, + }); + const droppedUnselected = withPassiveDroppedEvents( + structuredClone(before), + { network_requests: 1 }, + ); + droppedUnselected.completeness.excluded.push({ + section: "json_body_shapes", + reason: "not_approved", + count: null, + }); + droppedUnselected.network.requests[0]!.body_shapes.status = "not_approved"; const mcp = surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; for (const [name, left, right, change] of [ @@ -67,6 +90,26 @@ cliTest.for(["CLI", "stdio MCP"] as const)( unknownIdentityAdded, "added", ], + ["dropped request count", droppedRequest, after, null], + ["dropped request count reversed", after, droppedRequest, null], + [ + "dropped request with shapes unselected", + droppedUnselected, + droppedUnselected, + null, + ], + [ + "WebSocket drops do not hide HTTP additions", + droppedWebSocket, + after, + "added", + ], + [ + "unrelated drops do not hide HTTP additions", + unrelatedDrop, + after, + "added", + ], ] as const) { const input = { before: { inspection: left }, diff --git a/tests/fixtures/passiveNetworkCapture.ts b/tests/fixtures/passiveNetworkCapture.ts index 8192751bc26..1f563bdafdd 100644 --- a/tests/fixtures/passiveNetworkCapture.ts +++ b/tests/fixtures/passiveNetworkCapture.ts @@ -1,4 +1,9 @@ import { webPageInspectionSchema } from "../../src/domain/browserObservationSchemas.js"; +import type { WebPageInspection } from "../../src/domain/browserObservationSchemas.js"; +import { + classifyBrowserCompleteness, + type BrowserDroppedEventKind, +} from "../../src/domain/browserCompleteness.js"; /** Owned normalized comparison input, not a claim about live CDP inventory coverage. */ export const passiveNetworkCapture = (paths: readonly string[] = ["/a"]) => { @@ -102,3 +107,23 @@ export const passiveNetworkCapture = (paths: readonly string[] = ["/a"]) => { ], }); }; + +/** Use the production completeness classifier without inventing section-list entries. */ +export const withPassiveDroppedEvents = ( + capture: WebPageInspection, + dropped: Partial>, +): WebPageInspection => { + const { completeness } = capture; + const { total: _total, ...counts } = completeness.dropped_events; + return { + ...capture, + completeness: classifyBrowserCompleteness({ + policyFilteredSections: new Set(completeness.policy_filtered_sections), + attachLimitedSections: new Set(completeness.attach_limited_sections), + truncatedSections: new Set(completeness.truncated_sections), + unavailableSections: new Set(completeness.unavailable_sections), + excluded: completeness.excluded, + droppedEvents: { ...counts, ...dropped }, + }), + }; +}; From aff46f51fdb38f3c1c481af59099c1c4de837161 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:16:24 +0000 Subject: [PATCH 06/23] test(docs): retarget translated readme link drift at current links e572c38dc replaced the localized guide URLs in the translated readmes with repository-relative guide links, so the drift fixture no longer contained `https://rea.tools/zh-CN/guides/native/`. Exercise the same missing-target, wrong-locale, and query drift on a localized showcase link the readme still carries, and add drift for the new relative guide link. Co-Authored-By: Claude Opus 5.5 --- tests/boundary/filesystem/productCatalog.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/boundary/filesystem/productCatalog.test.ts b/tests/boundary/filesystem/productCatalog.test.ts index cabf725709a..ecdf5e51233 100644 --- a/tests/boundary/filesystem/productCatalog.test.ts +++ b/tests/boundary/filesystem/productCatalog.test.ts @@ -57,17 +57,18 @@ const README_LINK_DRIFT_MUTATIONS = [ "(../installation.md#supported-agents)", "(../installation.md#missing-agents)", ], + ["(../native-investigation.md)", "(../missing-investigation.md)"], [ - "https://rea.tools/zh-CN/guides/native/", + "https://rea.tools/zh-CN/showcase/dx-ball/", "https://rea.tools/zh-CN/missing/", ], [ - "https://rea.tools/zh-CN/guides/native/", - "https://rea.tools/zh-TW/guides/native/", + "https://rea.tools/zh-CN/showcase/dx-ball/", + "https://rea.tools/zh-TW/showcase/dx-ball/", ], [ - "https://rea.tools/zh-CN/guides/native/", - "https://rea.tools/zh-CN/guides/native/?other=1", + "https://rea.tools/zh-CN/showcase/dx-ball/", + "https://rea.tools/zh-CN/showcase/dx-ball/?other=1", ], [ 'src="../assets/rea-hopper-analysis.png"', From 0fdac09259eabafc7f852723c20d034e324590c2 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:36:45 +0000 Subject: [PATCH 07/23] fix(javascript): admit candidate endpoint interpretation in worker output b58adafa4 emits `interpretation: "candidate"` on static endpoint findings, but the worker's strict endpoint schema still rejected the key, so `analyze_javascript_application` failed output validation for any source with a candidate endpoint. Admit the field as an exact optional literal. Update application acceptance and boundary expectations to current main: endpoint identity keys tag literal values (88afcc738), source-map observations and edges carry `resolution_status` (b58adafa4), and a proven Webpack bootstrap reports bundled contextBridge exposures as direct syntax. Co-Authored-By: Claude Opus 5.5 --- .../analysis/JavaScriptAnalysisWorkerSchemas.ts | 1 + .../applications/applicationWorkflowCli.test.ts | 14 ++++++++++++-- .../applications/blockSourceMapCli.test.ts | 7 ++++++- .../applications/javascriptEndpointPayload.test.ts | 4 +++- .../javascriptApplicationEmptyLiterals.test.ts | 14 +++++++++----- 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index b8358ec4761..63ad24b629c 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -328,6 +328,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z kind: z.enum(["route", "network"]), value: z.string(), mechanism: z.string(), + interpretation: z.literal("candidate").exactOptional(), }), ), storage: z.array( diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index ed1ca8d1695..eefa8a7f955 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -485,6 +485,7 @@ it("preserves complete line-comment source-map URLs through CLI and MCP", async declared_url: declaredUrl, declared_url_sha256: digest, resolved_path: "app.js.map", + resolution_status: "resolved", available: true, }, evidence: expect.objectContaining({ @@ -507,6 +508,7 @@ it("preserves complete line-comment source-map URLs through CLI and MCP", async properties: { declared_url_sha256: digest, resolved_path: "app.js.map", + resolution_status: "resolved", }, }), ); @@ -565,6 +567,7 @@ it("resolves source-map URL paths once without changing their declarations", asy declared_url: declaredUrl, declared_url_sha256: digest, resolved_path: path, + resolution_status: "resolved", available: true, }, evidence: expect.objectContaining({ @@ -587,7 +590,11 @@ it("resolves source-map URL paths once without changing their declarations", asy expect.objectContaining({ relation: "maps_to", target_node_id: map?.node_id, - properties: { declared_url_sha256: digest, resolved_path: path }, + properties: { + declared_url_sha256: digest, + resolved_path: path, + resolution_status: "resolved", + }, }), ); } @@ -1576,8 +1583,11 @@ it("retains bundled isolated-world contextBridge APIs through dedicated and gene world: "isolated", world_id: { status: "literal", value: 1001, expression: null }, api_key: "isolatedApi", + api_status: "object-literal", members: ["ping"], - resolution_basis: "wrapper-substitution", + unknown_members: 0, + // The proven Webpack bootstrap resolves `load(2)` directly. + resolution_basis: "direct-syntax", }), evidence: expect.objectContaining({ location: expect.objectContaining({ diff --git a/tests/acceptance/applications/blockSourceMapCli.test.ts b/tests/acceptance/applications/blockSourceMapCli.test.ts index 957cd49c269..4e7cb7e1153 100644 --- a/tests/acceptance/applications/blockSourceMapCli.test.ts +++ b/tests/acceptance/applications/blockSourceMapCli.test.ts @@ -73,6 +73,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( declared_url: url, declared_url_sha256: digest, resolved_path: url, + resolution_status: "resolved", available: true, }, evidence: expect.objectContaining({ @@ -92,7 +93,11 @@ cliTest.for(["CLI", "stdio MCP"] as const)( expect.objectContaining({ relation: "maps_to", target_node_id: map?.node_id, - properties: { declared_url_sha256: digest, resolved_path: url }, + properties: { + declared_url_sha256: digest, + resolved_path: url, + resolution_status: "resolved", + }, }), ); }, diff --git a/tests/acceptance/applications/javascriptEndpointPayload.test.ts b/tests/acceptance/applications/javascriptEndpointPayload.test.ts index 6a980aa7ade..75b54e26e42 100644 --- a/tests/acceptance/applications/javascriptEndpointPayload.test.ts +++ b/tests/acceptance/applications/javascriptEndpointPayload.test.ts @@ -108,7 +108,9 @@ cliTest( ), ).toHaveLength(1); expect(repeatedNode.observations).toHaveLength(2); - expect(repeatedNode.identity).toMatchObject({ key: "/ok" }); + expect(repeatedNode.identity).toMatchObject({ + key: JSON.stringify(["literal", "/ok"]), + }); expect( repeatedNode.observations.every(({ label }) => label === "/ok"), ).toBe(true); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 0490ae21167..8387acdaae1 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts @@ -79,6 +79,10 @@ it("retains empty literal evidence across the application boundary surfaces", as ); }); +/** Endpoint identity keys tag literal values apart from candidates. */ +const literalEndpointKey = (value: string): string => + JSON.stringify(["literal", value]); + it("keeps bridge entry paths stable through worker validation and API seed matching", async () => { const output = await analyzeSource(` const { contextBridge, ipcRenderer } = require("electron"); @@ -144,7 +148,7 @@ it("analyzes a plain long-line loader without package metadata", async () => { for (const node of nodes) { expect(node.identity).toMatchObject({ strategy: "artifact-local-key", - key: "", + key: node.kind === "endpoint" ? literalEndpointKey("") : "", }); expect(node.observations.map(({ label }) => label)).toEqual([null]); } @@ -165,9 +169,9 @@ it("keeps exact values for empty literals without synthetic application labels", // a literal two-quote endpoint; both keep exact identities. expect(endpoints).toEqual( expect.arrayContaining([ - { key: "", labels: [null], values: [""] }, - { key: '""', labels: ['""'], values: ['""'] }, - { key: "/api", labels: ["/api"], values: ["/api"] }, + { key: literalEndpointKey(""), labels: [null], values: [""] }, + { key: literalEndpointKey('""'), labels: ['""'], values: ['""'] }, + { key: literalEndpointKey("/api"), labels: ["/api"], values: ["/api"] }, ]), ); expect(endpoints).toHaveLength(3); @@ -252,7 +256,7 @@ it("does not match an empty endpoint with a literal two-quote seed", async () => case_sensitive: true, }).filter(({ node_id: nodeId }) => endpoints.has(nodeId)); expect(matches.map(({ node_id: nodeId }) => endpoints.get(nodeId))).toEqual([ - '""', + literalEndpointKey('""'), ]); }); From 7346397106544c5aa262b0daabfd8ef84079f95e Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:40:48 +0000 Subject: [PATCH 08/23] fix(ghidra): keep earlier LaunchSupport warnings when a later helper fails d4bd65c53 moved a failed LaunchSupport helper's output into the nested `command_outcome`, but the cause projection also returned early for those failures and dropped `launch_support`. A `-vmargs` failure therefore lost the warning that the successful `-envvars` helper had already reported. Keep the retained helper outputs and skip only the launcher outcome that the nested cause now owns. Update the Ghidra launch and Hopper startup boundary expectations to current main: failed helpers report through `cause.command_outcome`, and bridge startup results carry the launched `processId` (599a8ee11), asserted against the exact retried process. Co-Authored-By: Claude Opus 5.5 --- src/ghidra/GhidraSessionError.ts | 4 ++- .../providers/ghidra/ghidraJavaLaunch.test.ts | 20 ++++++++++----- .../hopperClient.startupFailures.test.ts | 25 +++++++++++++++---- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index dedf27bd423..9f4abfb52d6 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -263,7 +263,6 @@ const causeLaunchOutcome = ( redact: (value: string, incomplete?: boolean) => string, ): Record => { if (!(cause instanceof GhidraLaunchError)) return {}; - if (cause.cause instanceof GhidraLaunchSupportError) return {}; const launchSupport = cause.launchSupport.length === 0 ? {} @@ -273,6 +272,9 @@ const causeLaunchOutcome = ( stderr: redact(output.stderr), })), }; + // A failed LaunchSupport helper reports its own command outcome in the + // nested cause; earlier helper warnings still belong to this launch. + if (cause.cause instanceof GhidraLaunchSupportError) return launchSupport; const output = cause.capturedOutput; if (output === undefined) return launchSupport; const stdout = boundedUtf8Text( diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 9b80722e7d7..09ad518ab28 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -188,9 +188,13 @@ describe.skipIf(process.platform === "win32")( const cause = failure.diagnostics.failure_cause; if (mode === "env-failure") { expect(cause).toMatchObject({ - launch_outcome: { - exit_code: 3, - stderr: expect.stringContaining(`-envvars: ${warning}`), + cause: { + name: "GhidraLaunchSupportError", + command_outcome: { + killed: false, + stderr: expect.stringContaining(`-envvars: ${warning}`), + }, + cause: { code: 3 }, }, }); } else { @@ -205,9 +209,13 @@ describe.skipIf(process.platform === "win32")( } if (mode === "vm-failure") expect(cause).toMatchObject({ - launch_outcome: { - exit_code: 3, - stderr: expect.stringContaining(`-vmargs: ${warning}`), + cause: { + name: "GhidraLaunchSupportError", + command_outcome: { + killed: false, + stderr: expect.stringContaining(`-vmargs: ${warning}`), + }, + cause: { code: 3 }, }, }); if (mode === "vm-malformed") diff --git a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts index 3d96ddb67bb..391a7a47bd5 100644 --- a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts @@ -41,6 +41,7 @@ class CancelThenFixtureLauncher implements BridgeLauncher { readonly firstLaunch = new Promise((resolve) => { this.#firstLaunchReady = resolve; }); + retriedProcess: ChildProcess | undefined; async launch(session: BridgeSession) { this.#launches += 1; @@ -49,6 +50,7 @@ class CancelThenFixtureLauncher implements BridgeLauncher { : new FixtureLauncher().launch(session)); if (this.#launches === 1 && launched.ok) this.#firstLaunchReady?.(launched.value.process); + else if (launched.ok) this.retriedProcess = launched.value.process; return launched; } } @@ -256,9 +258,17 @@ describe("HopperClient startup failures", () => { expect( firstProcess.exitCode !== null || firstProcess.signalCode !== null, ).toBe(true); - await expect(client.start()).resolves.toEqual({ + const restarted = await client.start(); + const retriedProcess = launcher.retriedProcess; + expect(retriedProcess).toBeDefined(); + expect(retriedProcess).not.toBe(firstProcess); + expect(restarted).toEqual({ ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, + value: { + name: "REA Hopper bridge", + version: "1.0.0", + processId: retriedProcess?.pid, + }, }); await expect( client.callTool("echo", { value: "retried" }), @@ -282,11 +292,16 @@ describe("HopperClient startup failures", () => { error: { _tag: "HopperCancelledError" }, }); expect(launcher.processes).toHaveLength(0); - await expect(client.start()).resolves.toEqual({ + const started = await client.start(); + expect(launcher.processes).toHaveLength(1); + expect(started).toEqual({ ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, + value: { + name: "REA Hopper bridge", + version: "1.0.0", + processId: launcher.processes[0]?.pid, + }, }); - expect(launcher.processes).toHaveLength(1); await expect( client.callTool("echo", { value: "retried" }), ).resolves.toEqual({ From 1b75c0d2f9fcaa89171aa1afdfc95b9b7ecb8dbc Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:54:39 +0000 Subject: [PATCH 09/23] fix(browser): read DOMSnapshot's -1 string index as an empty value Chrome's DOMSnapshot interns empty or null strings as index -1. bffdd9105 began rejecting every negative string index, so real Chrome snapshots lost each element node (whose nodeValue is -1) and any node with an empty attribute value, and the headless Chrome SVG xlink:href lanes found no anchors. Simulated snapshots interned "" as a table entry and missed it. Parse snapshot strings at one owner: names still require a table entry, while node values, attribute values, and baseURL accept -1 as "". The SVG ancestry projection shares the same parser instead of its own copy. Co-Authored-By: Claude Opus 5.5 --- src/browser/CdpCaptureDocuments.test.ts | 62 ++++++++++++++++++++++++- src/browser/CdpCaptureDocuments.ts | 35 ++++++-------- src/browser/CdpCaptureDomSvg.ts | 40 ++++++---------- src/browser/CdpSnapshotStrings.ts | 39 ++++++++++++++++ 4 files changed, 127 insertions(+), 49 deletions(-) create mode 100644 src/browser/CdpSnapshotStrings.ts diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index fe67f90aa80..be8656e8a47 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -130,14 +130,13 @@ describe("CDP DOM string and namespace validity", () => { { field: "nodeName", index: -1 }, { field: "nodeName", index: 20 }, { field: "nodeName", index: 2, malformedString: true }, - { field: "nodeValue", index: -1 }, { field: "nodeValue", index: 20 }, { field: "nodeValue", index: 5, malformedString: true }, { field: "attributeName", index: -1 }, { field: "attributeName", index: 20 }, - { field: "attributeValue", index: -1 }, { field: "attributeValue", index: 20 }, ] as const)( + // -1 is DOMSnapshot's empty string for values, never for names. "omits a node when its $field string index is invalid ($index)", ({ field, @@ -353,3 +352,62 @@ describe("CDP accessibility capture", () => { }); }); }); + +describe("CDP DOM snapshot empty strings", () => { + it("reads Chrome's -1 string index as an empty node or attribute value", () => { + const completeness = new CdpCaptureCompleteness(); + const generator = captureDom( + { + strings: [ + origin + "/app", + "#document", + "A", + "href", + "hidden", + "#text", + "go", + ], + documents: [ + { + documentURL: 0, + baseURL: -1, + nodes: { + nodeType: [9, 1, 3], + nodeName: [1, 2, 5], + nodeValue: [-1, -1, 6], + parentIndex: [-1, 0, 1], + attributes: [[], [3, -1, 4, -1], []], + }, + }, + ], + }, + new Set([origin]), + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + }), + completeness, + ); + let captured = generator.next([]); + while (!captured.done) captured = generator.next([]); + expect( + captured.value.nodes.map( + ({ node_name, node_value_length, attribute_names }) => ({ + node_name, + node_value_length, + attribute_names, + }), + ), + ).toEqual([ + { node_name: "#document", node_value_length: 0, attribute_names: [] }, + { + node_name: "A", + node_value_length: 0, + attribute_names: ["href", "hidden"], + }, + { node_name: "#text", node_value_length: 2, attribute_names: [] }, + ]); + expect(completeness.snapshot().excluded).toEqual([]); + }); +}); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 8aeee7e9a7d..f9f97d3750a 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -9,6 +9,11 @@ import { cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; +import { + cdpSnapshotAttributesResolve, + cdpSnapshotString, + cdpSnapshotValueString, +} from "./CdpSnapshotStrings.js"; import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { @@ -183,7 +188,7 @@ export function* captureDom( let total = 0; let unresolvedSvgNodes = 0; for (const document of recordsValue(root.documents)) { - const documentUrl = indexedString(strings, document.documentURL); + const documentUrl = cdpSnapshotString(strings, document.documentURL); if (documentUrl === undefined) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -195,7 +200,7 @@ export function* captureDom( const baseUrlValue = document.baseURL === undefined ? documentUrl - : indexedString(strings, document.baseURL); + : cdpSnapshotValueString(strings, document.baseURL); if (baseUrlValue === undefined) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -248,8 +253,8 @@ export function* captureDom( const nodeValueIndex = rawNodeValues[index]; const parent = rawParents[index]; const attributeIndexes = numberArray(rawAttributes[index]); - const nodeName = indexedString(strings, nodeNameIndex); - const nodeValue = indexedString(strings, nodeValueIndex); + const nodeName = cdpSnapshotString(strings, nodeNameIndex); + const nodeValue = cdpSnapshotValueString(strings, nodeValueIndex); if ( nodeType === undefined || nodeName === undefined || @@ -259,11 +264,7 @@ export function* captureDom( parent === index || parent >= rawNodeTypes.length || attributeIndexes === undefined || - attributeIndexes.length % 2 !== 0 || - attributeIndexes.some( - (value) => - value === undefined || indexedString(strings, value) === undefined, - ) + !cdpSnapshotAttributesResolve(strings, attributeIndexes) ) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -514,16 +515,6 @@ const accessibilityStates = ( const axText = (value: unknown): string | null => cdpStringValue(recordValue(value)?.value) ?? null; -const indexedString = ( - strings: readonly (string | undefined)[], - index: unknown, -): string | undefined => { - const integer = numberValue(index); - if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) - return undefined; - return strings[integer]; -}; - const uniqueAttributeNames = ( attributeIndexes: readonly number[], strings: readonly (string | undefined)[], @@ -531,7 +522,7 @@ const uniqueAttributeNames = ( const names: string[] = []; const seen = new Set(); for (let index = 0; index < attributeIndexes.length; index += 2) { - const name = indexedString(strings, attributeIndexes[index]); + const name = cdpSnapshotString(strings, attributeIndexes[index]); if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); @@ -597,8 +588,8 @@ const domMetadata = ( const pairs = new Map(); for (let index = 0; index + 1 < attributes.length; index += 2) { // Chrome already folds HTML names; SVG/XML names remain case-sensitive. - const name = indexedString(strings, attributes[index]); - const value = indexedString(strings, attributes[index + 1]); + const name = cdpSnapshotString(strings, attributes[index]); + const value = cdpSnapshotValueString(strings, attributes[index + 1]); if (name === undefined || value === undefined) continue; // HTML keeps the first declaration. A later repeat must not move dom_urls. if (!pairs.has(name)) pairs.set(name, value); diff --git a/src/browser/CdpCaptureDomSvg.ts b/src/browser/CdpCaptureDomSvg.ts index 2ffd8e31fdd..877bab06d11 100644 --- a/src/browser/CdpCaptureDomSvg.ts +++ b/src/browser/CdpCaptureDomSvg.ts @@ -1,4 +1,9 @@ import { numberValue, recordValue } from "./CdpCaptureValues.js"; +import { + cdpSnapshotAttributesResolve, + cdpSnapshotString, + cdpSnapshotValueString, +} from "./CdpSnapshotStrings.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; @@ -52,16 +57,6 @@ interface DomSvgLink { readonly xlinkHref: string | undefined; } -const stringAt = ( - strings: readonly (string | undefined)[], - index: unknown, -): string | undefined => { - const integer = numberValue(index); - if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) - return undefined; - return strings[integer]; -}; - const numberArray = (value: unknown): readonly (number | undefined)[] => Array.isArray(value) ? value.map((item) => { @@ -86,7 +81,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => { for (let index = 0; index < nodes.nodeTypes.length; index += 1) { if (nodes.nodeTypes[index] !== 1) continue; if (nodes.parents[index] !== documentIndex) continue; - const name = stringAt(nodes.strings, nodes.nodeNames[index]); + const name = cdpSnapshotString(nodes.strings, nodes.nodeNames[index]); if (name === undefined || !validSvgSourceNode(nodes, index)) return undefined; if (name === "HTML") return false; @@ -96,7 +91,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => { const attributes = numberArray(nodes.attributes[index]); for (let offset = 0; offset + 1 < attributes.length; offset += 2) { const value = attributes[offset]; - const attributeName = stringAt(nodes.strings, value); + const attributeName = cdpSnapshotString(nodes.strings, value); if (attributeName === undefined) return undefined; if (xmlnsPrefix(attributeName) !== undefined) return true; } @@ -195,7 +190,7 @@ const resolveXmlElements = ( const svgElement = nodes.nodeTypes[node] === 1 && elementNamespace( - stringAt(nodes.strings, nodes.nodeNames[node]), + cdpSnapshotString(nodes.strings, nodes.nodeNames[node]), bindings, ) === SVG_NAMESPACE; resolved.set(node, { @@ -223,8 +218,8 @@ const applyDeclarations = ( const nameIndex = attributes[index]; const valueIndex = attributes[index + 1]; if (nameIndex === undefined || valueIndex === undefined) continue; - const name = stringAt(strings, nameIndex); - const value = stringAt(strings, valueIndex); + const name = cdpSnapshotString(strings, nameIndex); + const value = cdpSnapshotValueString(strings, valueIndex); if (name === undefined || value === undefined) continue; const prefix = xmlnsPrefix(name); if (prefix === undefined || seen.has(prefix)) continue; @@ -277,7 +272,7 @@ const xlinkHrefValue = ( const nameIndex = attributes[index]; const valueIndex = attributes[index + 1]; if (nameIndex === undefined || valueIndex === undefined) continue; - const name = stringAt(strings, nameIndex); + const name = cdpSnapshotString(strings, nameIndex); if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); @@ -286,7 +281,7 @@ const xlinkHrefValue = ( const prefix = name.slice(0, colon); const local = name.slice(colon + 1); if (local !== "href" || bindings.get(prefix) !== XLINK_NAMESPACE) continue; - return stringAt(strings, valueIndex); + return cdpSnapshotValueString(strings, valueIndex); } return undefined; }; @@ -297,19 +292,14 @@ const validSvgSourceNode = ( ): boolean => { if ( nodes.nodeTypes[index] === undefined || - stringAt(nodes.strings, nodes.nodeNames[index]) === undefined + cdpSnapshotString(nodes.strings, nodes.nodeNames[index]) === undefined ) return false; const rawAttributes = nodes.attributes[index]; - if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0) - return false; + if (!Array.isArray(rawAttributes)) return false; const attributes = numberArray(rawAttributes); return ( attributes.length === rawAttributes.length && - attributes.every( - (attribute) => - attribute !== undefined && - stringAt(nodes.strings, attribute) !== undefined, - ) + cdpSnapshotAttributesResolve(nodes.strings, attributes) ); }; diff --git a/src/browser/CdpSnapshotStrings.ts b/src/browser/CdpSnapshotStrings.ts new file mode 100644 index 00000000000..6e83147277a --- /dev/null +++ b/src/browser/CdpSnapshotStrings.ts @@ -0,0 +1,39 @@ +import { numberValue } from "./CdpCaptureValues.js"; + +// DOMSnapshot interns an empty or null string as index -1 instead of a table +// entry. Chrome uses it for element node values and empty attribute values. +const CDP_EMPTY_STRING_INDEX = -1; + +/** Resolve a DOMSnapshot name index, which must reference a string entry. */ +export const cdpSnapshotString = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => { + const integer = numberValue(index); + if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) + return undefined; + return strings[integer]; +}; + +/** Resolve a DOMSnapshot value index, where -1 is the protocol's empty string. */ +export const cdpSnapshotValueString = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => + numberValue(index) === CDP_EMPTY_STRING_INDEX + ? "" + : cdpSnapshotString(strings, index); + +/** Whether flattened name/value attribute indexes all resolve. */ +export const cdpSnapshotAttributesResolve = ( + strings: readonly (string | undefined)[], + attributes: readonly (number | undefined)[], +): boolean => + attributes.length % 2 === 0 && + attributes.every( + (attribute, offset) => + attribute !== undefined && + (offset % 2 === 0 + ? cdpSnapshotString(strings, attribute) + : cdpSnapshotValueString(strings, attribute)) !== undefined, + ); From 136a157ea5c70044fe55aca52450fc53fafc5653 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:56:56 +0000 Subject: [PATCH 10/23] test(browser): expect SAZ expansion limits as caller-correctable input 29a2d1399 classifies an over-budget expanded SAZ archive as invalid input with an out_of_range `capture_path` issue that names the expanded-byte maximum. Assert that projection through the CLI instead of the previous execution failure. Co-Authored-By: Claude Opus 5.5 --- tests/boundary/browser/sazWebCapture.test.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index 69eca33b843..d6192e2e691 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -10,7 +10,10 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it as runtimeTest } from "vitest"; import { z } from "zod"; import { parseEvidence } from "../../../src/domain/evidence.js"; -import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js"; +import { + WEB_NETWORK_CAPTURE_LIMITS, + webNetworkCaptureSchema, +} from "../../../src/domain/webNetworkCapture.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -478,12 +481,19 @@ it.each([ expect(JSON.parse(result.stdout).category).toBe( ["encrypted", "unknown-member", "dtd"].includes(variant) ? "unsupported_provider" - : variant === "expanded-limit" - ? "execution_failure" - : "invalid_input", + : "invalid_input", ); + // Expanded size is a caller-correctable input bound, not a decoder failure. if (variant === "expanded-limit") - expect(JSON.parse(result.stdout).code).toBe("unreadable_output"); + expect(JSON.parse(result.stdout).details.issues).toEqual([ + expect.objectContaining({ + path: ["capture_path", ""], + reason: "out_of_range", + expected: { + maximum_expanded_capture_bytes: WEB_NETWORK_CAPTURE_LIMITS.inputBytes, + }, + }), + ]); expect(JSON.stringify(result)).not.toContain("malformed-private-input"); }); From bb1fbf677707c85399577586ab1e9f8d17665a9b Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:01:36 +0000 Subject: [PATCH 11/23] test(artifacts): accept filesystem order for root entries before replacement DirectoryArtifactReader yields a directory's entries in `opendir()` order and reads queued child directories only after that directory is exhausted. The root-replacement test assumed APFS order (`sub` before `sample.txt`), so it failed on Linux ext4. Require both root entries in either order before the descent into the replaced root is rejected. Co-Authored-By: Claude Opus 5.5 --- .../filesystem/directoryArtifactReaderOwnership.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts b/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts index 0ca2e791ae9..852275c2c04 100644 --- a/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts +++ b/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts @@ -55,8 +55,13 @@ describe("DirectoryArtifactReader resource ownership", () => { const movedRoot = `${root}-original`; try { const entries = reader.entries()[Symbol.asyncIterator](); - expect((await entries.next()).value?.path).toBe("sub"); - expect((await entries.next()).value?.path).toBe("sample.txt"); + // Root entries follow the filesystem's directory order; descendants are + // read only after the root is exhausted. + const rootEntries = [ + (await entries.next()).value?.path, + (await entries.next()).value?.path, + ]; + expect(rootEntries.sort()).toEqual(["sample.txt", "sub"]); await rename(root, movedRoot); await mkdir(root); From 181f0f9d1c27859e5a85e578d378fdc3cff67a9b Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:05:11 +0000 Subject: [PATCH 12/23] test(cli): read the development runner summary without inherited color The exact-selection tests from 4d4631ac9 match the child Vitest summary as plain text. In the CI lane the child inherited color, so ANSI styling split "Test Files 2 passed" and both tests failed there while passing locally; `FORCE_COLOR=1` reproduces it. Run the child with `NO_COLOR=1`. Co-Authored-By: Claude Opus 5.5 --- tests/boundary/cli/developmentTests.test.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/boundary/cli/developmentTests.test.ts b/tests/boundary/cli/developmentTests.test.ts index d9b9fa7f187..3976781d5ac 100644 --- a/tests/boundary/cli/developmentTests.test.ts +++ b/tests/boundary/cli/developmentTests.test.ts @@ -13,12 +13,18 @@ import { describe, expect, it } from "vitest"; import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -const run = (root: string, command: string, arguments_: readonly string[]) => +const run = ( + root: string, + command: string, + arguments_: readonly string[], + env?: NodeJS.ProcessEnv, +) => execFileOutput(command, arguments_, { cwd: root, stopSignal: "SIGTERM", timeout: 30_000, maxBuffer: 1024 * 1024, + ...(env === undefined ? {} : { env }), }); const git = (root: string, ...arguments_: string[]) => @@ -72,11 +78,15 @@ const fixture = async () => { return root; }; +// Assertions read the child's plain-text summary. An inherited color setting +// styled the counts in the CI lane; NO_COLOR takes precedence over FORCE_COLOR. const testCommand = (root: string, ...arguments_: string[]) => - run(root, process.execPath, [ - "scripts/run-development-tests.mjs", - ...arguments_, - ]); + run( + root, + process.execPath, + ["scripts/run-development-tests.mjs", ...arguments_], + { ...process.env, NO_COLOR: "1" }, + ); describe("development test runner exact selection", () => { it("matches selected path casing to the host filesystem", async () => { From dab811b988cfb348cd54d199ba1e39dc243cb289 Mon Sep 17 00:00:00 2001 From: Omisakin Joshua <117924167+thefolahan@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:17:08 +0100 Subject: [PATCH 13/23] test(mcp): expect partner fields to be enforced by the canonical parser only (cherry picked from commit ea4cfc9f11fb002923c999839df3bba6984d0b23) --- docs/mcp-contracts.md | 7 ++--- .../mcp/browserComparisonInput.test.ts | 27 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 02218bf51f1..53df0b37749 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -768,9 +768,10 @@ identities remains unknown. `normalization` defaults to `{ "rules": [] }`. The advertised schema includes complete nested capture fields and rejects -incomplete pairs and structurally malformed captures. Mixed comparison families -can pass advertised validation; the SDK rejects them against the canonical -input schema before invoking the handler. Domain validation additionally checks +incomplete pairs and structurally malformed captures. Mixed comparison families, +and `normalization` without both scenario captures, can pass advertised +validation; the SDK rejects them against the canonical input schema before +invoking the handler. Domain validation additionally checks relationships such as event sequence references and retained counts; JSON Schema does not express those cross-field invariants. diff --git a/tests/boundary/mcp/browserComparisonInput.test.ts b/tests/boundary/mcp/browserComparisonInput.test.ts index 0dba6faffb7..2fcf38858b2 100644 --- a/tests/boundary/mcp/browserComparisonInput.test.ts +++ b/tests/boundary/mcp/browserComparisonInput.test.ts @@ -110,15 +110,6 @@ describe("browser comparison input boundary", () => { }, { before_scenario: {}, after_scenario: {} }, { before_scenario: scenario.before_scenario }, - { ...passive, normalization: { rules: [] } }, - { - before_scenario: scenario.before_scenario, - normalization: { rules: [] }, - }, - { - after_scenario: scenario.after_scenario, - normalization: { rules: [] }, - }, { ...passive, unexpected: true }, { ...scenario, @@ -133,8 +124,20 @@ describe("browser comparison input boundary", () => { }, }, ]; - // The projection leaves group exclusion to the canonical runtime parser. - const mixedGroups = [{ ...passive, ...scenario }]; + // The projection leaves group exclusion and partner fields to the + // canonical runtime parser. + const runtimeOnlyInvalid = [ + { ...passive, ...scenario }, + { ...passive, normalization: { rules: [] } }, + { + before_scenario: scenario.before_scenario, + normalization: { rules: [] }, + }, + { + after_scenario: scenario.after_scenario, + normalization: { rules: [] }, + }, + ]; for (const { input, advertisedValid, runtimeValid } of [ ...[passive, scenario, withoutNormalization].map((input) => ({ input, @@ -146,7 +149,7 @@ describe("browser comparison input boundary", () => { advertisedValid: false, runtimeValid: false, })), - ...mixedGroups.map((input) => ({ + ...runtimeOnlyInvalid.map((input) => ({ input, advertisedValid: true, runtimeValid: false, From cd47a8f5a6e28aadbcd55b08021a3cc24a89e2eb Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:04:28 +0800 Subject: [PATCH 14/23] test(contracts): verify public admission failure boundaries Assert SAZ expansion-limit code and byte constraint through stdio MCP as well as CLI, without partial results or source mutation. Align schema presentation guidance with canonical runtime partner validation. The general MCP documentation paragraph preserves the upstream explanation from 5447a5d in PR #2877. Co-authored-by: Omisakin Joshua <117924167+thefolahan@users.noreply.github.com> (cherry picked from commit 5d906ecbe1ef7e6ca9e2129dd9dbc25d6145d1e9) --- docs/mcp-contracts.md | 12 ++++++--- docs/web-network-captures.md | 6 ++++- src/contracts/inputSchemaPresentation.ts | 4 +-- tests/boundary/browser/sazWebCapture.test.ts | 28 ++++++++++++++++++-- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 53df0b37749..b54c873eb9c 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -664,10 +664,14 @@ advertised. Every advertised example is a distinct valid canonical input. Root object unions are presented as one object without root `anyOf`, `oneOf`, or `allOf` for model API compatibility. -The projection merges properties, retains shared required fields and -group-specific field dependencies, and describes the accepted input groups. -Canonical runtime validation enforces the exact union, including exclusions -that the advertised projection does not express. Tests validate the schemas +The projection merges properties, retains shared required fields, and +describes the accepted input groups. Which fields belong together is guidance +in that description rather than a `dependentRequired` or `dependencies` +keyword, because Gemini function declarations reject both and one unknown +keyword fails the client's whole tool catalog. Canonical runtime validation +enforces the exact union, including partner fields and exclusions that the +advertised projection does not express, so a client must not rely on the +advertised schema to reject an incomplete group. Tests validate the schemas after SDK conversion and check advertised validation and actual calls. Individual model APIs can impose additional nesting limits; complete producer captures can exceed ten structural levels. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 2d5bdcae166..4d7f46b811b 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -110,7 +110,11 @@ gRPC message bytes. Missing members remain null, distinct from empty bodies. See Telerik's [SAZ format reference](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/knowledge-base/fiddler-archives). The existing ZIP reader verifies read members; expanded archive contents are -limited to 32 MiB in addition to the input/output budgets below. Encrypted +limited to 32 MiB in addition to the input/output budgets below. Exceeding this +expanded-byte budget returns `invalid_request` / `invalid_input` through both +CLI and MCP, with an `out_of_range` issue at +`capture_path` and `maximum_expanded_capture_bytes`. Selecting records does not +bypass the complete-archive admission check or return partial Evidence. Encrypted archives and unrecognized raw session members are unsupported. SAZ requires an enforced decoder memory bound: Node's fixed heap, or the Windows Job Object commit limit. Bun on hosts without that Job Object limit returns capability diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts index 3ed83907a19..740f1f3faef 100644 --- a/src/contracts/inputSchemaPresentation.ts +++ b/src/contracts/inputSchemaPresentation.ts @@ -172,8 +172,8 @@ const flattenRootObjectUnion = ( variants.set(name, known); } const required = sharedRequiredProperties(branches); - // A field unique to a group still needs that group's required partners. - // Counting properties alone would let optional fields pad an incomplete pair. + // A coarse property minimum rejects underspecified objects. Partner fields + // and exact group membership remain enforced by the canonical runtime parser. const fewestRequired = Math.min( ...branches.map((branch) => requiredProperties(branch).length), ); diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index d6192e2e691..281c2f31b53 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -15,6 +15,7 @@ import { webNetworkCaptureSchema, } from "../../../src/domain/webNetworkCapture.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { @@ -484,8 +485,10 @@ it.each([ : "invalid_input", ); // Expanded size is a caller-correctable input bound, not a decoder failure. - if (variant === "expanded-limit") - expect(JSON.parse(result.stdout).details.issues).toEqual([ + if (variant === "expanded-limit") { + const error = JSON.parse(result.stdout); + expect(error.code).toBe("invalid_request"); + expect(error.details.issues).toEqual([ expect.objectContaining({ path: ["capture_path", ""], reason: "out_of_range", @@ -494,6 +497,27 @@ it.each([ }, }), ]); + const { call } = await connectLocalToolsMcp(); + const response = await call("inspect_web_network_capture", { + capture_path: path, + format: "saz", + record_ordinals: [0], + }); + expect(response.isError).toBe(true); + const mcpFailure = parseMcpToolError(response); + expect(mcpFailure).toMatchObject({ + error: { + code: error.code, + category: error.category, + details: error.details, + }, + }); + expect(error).not.toHaveProperty("normalized_result"); + expect(error.details).not.toHaveProperty("partial_observation"); + expect(mcpFailure).not.toHaveProperty("normalized_result"); + expect(mcpFailure.error.details).not.toHaveProperty("partial_observation"); + expect(await readFile(path)).toEqual(bytes); + } expect(JSON.stringify(result)).not.toContain("malformed-private-input"); }); From fd4af675a15b530094ef78794a2afa6023792ead Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:06:24 +0800 Subject: [PATCH 15/23] test(javascript): preserve candidate endpoints across worker validation (cherry picked from commit 4dc37dc8210509c66b2fe4313525736978b8bd07) --- .../JavaScriptAnalysisWorkerSchemas.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index bb6f8b5855b..87c7092f50f 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -3,6 +3,32 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; import { javaScriptWorkerStaticAnalysisSchema } from "./JavaScriptAnalysisWorkerSchemas.js"; +it("retains candidate endpoint interpretation across the worker boundary", () => { + const facts = analyzeJavaScriptStaticSource(` + client.get("settingKey"); + fetch("/api"); + `); + expect(facts.endpoints).toEqual([ + expect.objectContaining({ + value: "settingKey", + interpretation: "candidate", + }), + expect.objectContaining({ value: "/api" }), + ]); + expect(facts.endpoints[1]).not.toHaveProperty("interpretation"); + const serialized = JSON.parse(JSON.stringify(facts)) as unknown; + expect(javaScriptWorkerStaticAnalysisSchema.parse(serialized)).toEqual(facts); + expect( + javaScriptWorkerStaticAnalysisSchema.safeParse({ + ...facts, + endpoints: facts.endpoints.map((endpoint) => ({ + ...endpoint, + interpretation: "confirmed", + })), + }).success, + ).toBe(false); +}); + it("versions fingerprints through the worker schema and rejects v1", () => { const facts = analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], { From 3d3076a749f50fee5b807c65bed8a9b36c3ea998 Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:06:33 +0800 Subject: [PATCH 16/23] test(ci): verify scoped caveats and invalid DOM snapshot values Check one copy of each generic caveat in the workflow result and one in its Evidence envelope, following the scoped assertion proposed by N0zoM1z0 in PR #2933. Keep distinct caveats and confirm a later trace does not inherit a caveat from an observation only used by the earlier graph. Retain explicit -2 node/attribute value rejection alongside the valid -1 empty-string regression supplied by the upstream browser repair. Validation: 88 source tests, 24 boundary tests (including all three real Chrome SVG/XHTML lanes), npm run check, and test discovery passed on Node 24.18.0/npm 11.16.0 and Chrome 155.0.8059.79 on macOS 15.7.7. (cherry picked from commit 2cd292ca495ddb57fc6ac0f7f51449aa4fc6e95c) --- src/browser/CdpCaptureDocuments.test.ts | 2 ++ .../enhancedTools.callPathsAndBatches.test.ts | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index be8656e8a47..4f81c3f71a1 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -130,10 +130,12 @@ describe("CDP DOM string and namespace validity", () => { { field: "nodeName", index: -1 }, { field: "nodeName", index: 20 }, { field: "nodeName", index: 2, malformedString: true }, + { field: "nodeValue", index: -2 }, { field: "nodeValue", index: 20 }, { field: "nodeValue", index: 5, malformedString: true }, { field: "attributeName", index: -1 }, { field: "attributeName", index: 20 }, + { field: "attributeValue", index: -2 }, { field: "attributeValue", index: 20 }, ] as const)( // -1 is DOMSnapshot's empty string for values, never for names. diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index f4915afd137..f7cd6a2fefc 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -8,6 +8,7 @@ import { AnalysisCancelledError, AnalysisOutputError, } from "../../../src/domain/analysisErrorCore.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; import { err, ok as resultOk } from "../../../src/domain/result.js"; @@ -32,7 +33,7 @@ const relationshipExecution = ( ), ); -describe("enhanced MCP tools", () => { +describe("enhanced MCP relationship limitations", () => { it("preserves per-node relationship limitations and keeps observed edges", async () => { const limitation = "ARM64 call targets are only partially resolved."; const client = await connect({ @@ -91,9 +92,12 @@ describe("enhanced MCP tools", () => { }, }); }); +}); +describe("enhanced MCP provider caveats", () => { it("keeps generic provider caveats visible without truncating complete relationships", async () => { const caveat = "Decompiler output may omit indirect calls."; + const leafCaveat = "Source signatures are unavailable."; const client = await connect({ execute: async (name, arguments_) => { if (name === "procedure_address") @@ -102,7 +106,7 @@ describe("enhanced MCP tools", () => { throw new Error(`Unexpected operation: ${name}`); return relationshipExecution( arguments_.procedure === "0x1" ? ["0x2"] : [], - [caveat], + arguments_.procedure === "0x1" ? [caveat] : [caveat, leafCaveat], { status: "complete" }, ); }, @@ -122,12 +126,17 @@ describe("enhanced MCP tools", () => { { status: "ok", relationship_coverage: { status: "complete" } }, ], }, - limitations: [caveat], + limitations: [caveat, leafCaveat], }, }); - expect(JSON.stringify(graph.structuredContent)?.split(caveat)).toHaveLength( - 2, - ); + const evidence = parseEvidence(graph.structuredContent); + const normalized = JSON.stringify(evidence.normalized_result); + for (const item of [caveat, leafCaveat]) { + expect(normalized.split(item)).toHaveLength(2); + expect( + evidence.limitations.filter((limit) => limit === item), + ).toHaveLength(1); + } const trace = await client.callTool({ name: "trace_call_path", @@ -141,6 +150,11 @@ describe("enhanced MCP tools", () => { relationship_coverage: { "0x1": { status: "complete" } }, }, }); + const traceEvidence = parseEvidence(trace.structuredContent); + expect( + traceEvidence.limitations.filter((item) => item === caveat), + ).toHaveLength(1); + expect(JSON.stringify(traceEvidence)).not.toContain(leafCaveat); }); it("keeps unclassified legacy caveats on nodes with unknown coverage", async () => { From 525dd12953e73a169dae5d7e3ce6f4ea39b70b7a Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:20:57 +0800 Subject: [PATCH 17/23] test(runtime): align macOS saved database admission expectations Main d4bd65c intentionally refuses saved Hopper database analysis on macOS because its launcher cannot run the analysis bridge for that target kind. The production stdio smoke test still expected a successful fake reply. Assert the typed target_kind_unsupported Hopper rejection, retained target identity and an unbound provider instead. After the refusal, verify the same stdio session still serves its complete catalog and session status. Retain the structured warning/error record and no-fatal assertions. The unchanged test fails on clean main d901561 and the shared CI repair head 3d3076a. The updated runtime suite and Hopper discovery suite each pass all four cases on macOS 15.7.7, Node 24.18.0 and npm 11.16.0. npm run check passes. These tests use the explicit Node fake launcher; no live Hopper installation or user document is opened or modified. (cherry picked from commit 33ec4cee9c3e982c9712d082aec98fd035ce209f) --- tests/acceptance/applications/runtime.test.ts | 71 ++++++++++++++++--- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/tests/acceptance/applications/runtime.test.ts b/tests/acceptance/applications/runtime.test.ts index 80989478ce8..d085d27713a 100644 --- a/tests/acceptance/applications/runtime.test.ts +++ b/tests/acceptance/applications/runtime.test.ts @@ -13,6 +13,7 @@ import { toolAvailabilityInventory, } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; const mainPath = fileURLToPath( new URL("../../../dist/main.js", import.meta.url), @@ -69,10 +70,13 @@ const completeStderrRecords = (stderr: string): unknown[] => .filter((line) => line.length > 0) .map((line): unknown => JSON.parse(line)); -const callCurrentDocument = async (target?: { - readonly path: string; - readonly kind?: "database"; -}) => { +const callCurrentDocument = async ( + target?: { + readonly path: string; + readonly kind?: "database"; + }, + inspectSession?: (client: Client) => Promise, +) => { const transport = new StdioClientTransport({ command: process.execPath, args: [mainPath], @@ -105,6 +109,7 @@ const callCurrentDocument = async (target?: { name: "current_document", arguments: {}, }); + await inspectSession?.(client); } finally { await client.close(); await transport.close(); @@ -240,18 +245,62 @@ describe("production stdio runtime", () => { ); expect(fatal).toEqual([]); }); +}); +describe("production stdio saved-database admission", () => { it.runIf(process.platform === "darwin")( - "serves a database-kind initial target without a fatal record on macOS", + "refuses database analysis on macOS while keeping the session usable without a fatal record", async () => { - const { result, records } = await callCurrentDocument({ - path: fileURLToPath(import.meta.url), - kind: "database", + const path = fileURLToPath(import.meta.url); + const { result, records } = await callCurrentDocument( + { path, kind: "database" }, + async (client) => { + const status = await expectAvailableToolInventory( + client, + await client.listTools(), + ); + expect(status.structuredContent).toMatchObject({ + result: { + open: true, + path, + kind: "database", + format: "analysis-database", + analysis_provider_binding: null, + }, + }); + }, + ); + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "capability_unavailable", + category: "unsupported_provider", + retryable: false, + details: { + operation: "current_document", + selection_reason: "target_unsupported", + rejections: expect.arrayContaining([ + expect.objectContaining({ + provider_id: "hopper", + code: "target_kind_unsupported", + reason: expect.stringContaining( + "cannot start REA's analysis bridge", + ), + diagnostics: { + target_kind: "database", + target_format: "analysis-database", + architecture: null, + }, + }), + ]), + }, + }, }); - expect(result.isError).not.toBe(true); - evidenceResultOf(z.literal("fixture")).parse(result.structuredContent); expect(records).toContainEqual( - expect.objectContaining({ tool: "current_document", status: "ok" }), + expect.objectContaining({ + tool: "current_document", + status: "error", + level: 40, + }), ); expect( records.some( From 2018838b35f253df96c54c590a8ae4de782765cb Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:28:48 +0000 Subject: [PATCH 18/23] test(process): expose the demo helper fixture through the scanned proc root 599a8ee11 replaced the host-wide `ps` scan with a scan of the supplied proc root, but the reparented demo-helper case only mirrored its process into the synthetic root in alternate-launcher mode. The renamed `Xvfb` helper was therefore invisible and the clean-runtime check resolved. Mirror the fixture process in both modes. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit d4df0a6f93c573b8f54d17bae036edb06f689580) --- .../cleanLinuxHopperRuntime.test.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index 55fdf0ec9e2..00f02d6ecd0 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -74,15 +74,12 @@ it const { stdout } = await parent; const { pid } = JSON.parse(stdout) as { pid: number }; orphanPid = pid; - if (alternateMode) { - const processEntry = join(procRoot, String(pid)); - await mkdir(processEntry); - for (const name of ["cmdline", "comm", "exe"]) - await symlink( - `/proc/${String(pid)}/${name}`, - join(processEntry, name), - ); - } + // The runtime check reads only the supplied proc root, so expose the + // fixture there in both modes. + const processEntry = join(procRoot, String(pid)); + await mkdir(processEntry); + for (const name of ["cmdline", "comm", "exe"]) + await symlink(`/proc/${String(pid)}/${name}`, join(processEntry, name)); const { stdout: coordinates } = await execFileAsync("ps", [ "-p", String(pid), From 156b368f3c4bb63583d786c5b5434d054d7b09ae Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:44:16 +0800 Subject: [PATCH 19/23] fix(browser): keep ambiguous request groups out of content comparisons --- docs/browser-observation.md | 8 ++- src/domain/webCaptureDiff.network.test.ts | 54 +++++++++++++++++++ src/domain/webCaptureDiff.ts | 32 +++++++---- .../tools/passiveNetworkInventory.test.ts | 10 ++++ 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 76a0d2cd3ac..3877c8fd8ee 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -279,8 +279,12 @@ REA retains local observation data and removes only URL userinfo credentials, ex attach-limited inventory complete or establish body-shape equality. An unavailable HTTP resource type also leaves request identity incomplete: observations with the same method and URL remain possible matches across types, - and identical unknown types cannot establish complete equality. Different - methods/URLs and complete empty peer inventories still support absence claims. + and identical unknown types cannot establish complete equality. Ambiguous type + groups cannot prove membership or content changes either: a missing type may + redistribute repeated requests between buckets without changing the requests. + Their comparison remains unknown, while independently identified groups retain + proven changes. Different methods/URLs and complete empty peer inventories still + support absence claims. Relevant dropped-event counts also limit inventory completeness even when no section is listed as truncated: HTTP uses `network_requests`, while WebSocket uses `websocket_connections` and `websocket_frames`. Drops in other event kinds diff --git a/src/domain/webCaptureDiff.network.test.ts b/src/domain/webCaptureDiff.network.test.ts index dbff353a636..46c7956a4c3 100644 --- a/src/domain/webCaptureDiff.network.test.ts +++ b/src/domain/webCaptureDiff.network.test.ts @@ -248,6 +248,60 @@ describe("independent WebSocket inventory evidence", () => { }); }); +describe("repeated HTTP request identity uncertainty", () => { + it.each([true, false])( + "keeps repeated-request regrouping unknown when one type is missing (shapes selected=%s)", + (selected) => { + const before = passiveNetworkCapture(["/a", "/a"]); + const after = structuredClone(before); + after.network.requests[1]!.resource_type = null; + if (!selected) + for (const capture of [before, after]) unselectedShapes(capture); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + }, + ); + + it("retains a known repeated-request count change", () => { + const before = unselectedShapes(passiveNetworkCapture(["/a", "/a"])); + const after = unselectedShapes(passiveNetworkCapture()); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "modified" }], + }); + }); + + it("does not assign observed content to an ambiguous type bucket", () => { + const before = unselectedShapes(passiveNetworkCapture(["/a", "/a"])); + before.network.requests[1]!.resource_type = null; + before.network.requests[1]!.status = 503; + const after = structuredClone(before); + after.network.requests[0]!.status = 503; + after.network.requests[1]!.status = 200; + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + }); +}); + describe("nullable HTTP request identities", () => { it.each([true, false])( "keeps known/unknown resource-type matches uncertain (shapes selected=%s)", diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index 37aa5806313..77459ab7174 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -257,15 +257,15 @@ const networkIdentityCoverage = ( after: WebPageInspection, ): { readonly complete: boolean; - readonly unprovenAbsences: ReadonlySet; + readonly ambiguousIdentities: ReadonlySet; } => { const complete = [before, after].every((inspection) => inspection.network.requests.every( ({ resource_type }) => resource_type !== null, ), ); - const unprovenAbsences = new Set(); - if (complete) return { complete, unprovenAbsences }; + const ambiguousIdentities = new Set(); + if (complete) return { complete, ambiguousIdentities }; type Request = WebPageInspection["network"]["requests"][number]; const matchKey = (request: Request): string => JSON.stringify([request.method, request.url]); @@ -292,12 +292,12 @@ const networkIdentityCoverage = ( peerUnknown === true || (peerUnknown === false && request.resource_type === null) ) - unprovenAbsences.add(networkIdentity(request)); + ambiguousIdentities.add(networkIdentity(request)); } }; retainPotentialMatches(before, unknownTypes(after)); retainPotentialMatches(after, unknownTypes(before)); - return { complete, unprovenAbsences }; + return { complete, ambiguousIdentities }; }; const networkResponseFields = [ @@ -417,12 +417,22 @@ const networkDimension = ( ); const compared = compareDimension( new Map([ - ...networkMap(before, sources, responseFields), + ...networkMap( + before, + sources, + responseFields, + requestIdentities.ambiguousIdentities, + ), ...webSocketMetadataMap(beforeWebSockets), ...beforeWebSocketPayload, ]), new Map([ - ...networkMap(after, sources, responseFields), + ...networkMap( + after, + sources, + responseFields, + requestIdentities.ambiguousIdentities, + ), ...webSocketMetadataMap(afterWebSockets), ...afterWebSocketPayload, ]), @@ -434,12 +444,12 @@ const networkDimension = ( webSocketShapesComplete, requestIdentities.complete ? "Network capture or selected HTTP/WebSocket payload-shape coverage is limited, unavailable, incomplete, or includes dropped network events." - : "HTTP request identity is incomplete because a resource type is unavailable; absence of a potentially matching request and complete network equality cannot be established.", + : "HTTP request identity is incomplete because a resource type is unavailable; potentially matching type groups cannot establish membership/content changes or complete network equality.", // HTTP and WebSocket identities have independent inventory coverage. Shape // selection can prevent equality without erasing proven membership changes. (identity) => identity.startsWith("net_") - ? requestsComplete && !requestIdentities.unprovenAbsences.has(identity) + ? requestsComplete : identity.startsWith("ws_") && webSocketInventoryComplete, ); return compared.status === "unchanged" && !responseFieldsComplete @@ -456,10 +466,14 @@ const networkMap = ( inspection: WebPageInspection, sources: ReadonlyMap, responseFields: ReadonlyMap>, + ambiguousIdentities: ReadonlySet, ): ReadonlyMap => { const grouped = new Map(); for (const request of inspection.network.requests) { const identity = networkIdentity(request); + // A missing type can move repeated requests between these buckets. Neither + // their counts nor fingerprints establish a change to that typed group. + if (ambiguousIdentities.has(identity)) continue; const shapes = sources.get(identity); const values = grouped.get(identity) ?? []; const response: Record = {}; diff --git a/tests/acceptance/tools/passiveNetworkInventory.test.ts b/tests/acceptance/tools/passiveNetworkInventory.test.ts index bef20797dbe..a7812682944 100644 --- a/tests/acceptance/tools/passiveNetworkInventory.test.ts +++ b/tests/acceptance/tools/passiveNetworkInventory.test.ts @@ -36,6 +36,9 @@ cliTest.for(["CLI", "stdio MCP"] as const)( url: "wss://app.example.test/live", events: [], }); + const repeated = passiveNetworkCapture(["/a", "/a"]); + const repeatedUnknown = structuredClone(repeated); + repeatedUnknown.network.requests[1]!.resource_type = null; const unknownIdentity = structuredClone(before); unknownIdentity.network.requests[0]!.resource_type = null; const unknownIdentityAdded = structuredClone(after); @@ -76,6 +79,13 @@ cliTest.for(["CLI", "stdio MCP"] as const)( ["equal unavailable", before, before, null], ["incomplete inventory", incomplete, after, null], ["limited WebSocket inventory", wsBefore, wsAfter, "added"], + ["repeated request type uncertainty", repeated, repeatedUnknown, null], + [ + "repeated request type uncertainty reversed", + repeatedUnknown, + repeated, + null, + ], ["unknown request identity", before, unknownIdentity, null], ["unknown request identity reversed", unknownIdentity, before, null], [ From affae687ed97f97126e71c3cad4d67077bd22b19 Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:44:15 +0800 Subject: [PATCH 20/23] fix(javascript): retain escapes from uncertain process selections --- docs/javascript-application-workflows.md | 5 ++++ ...ptSemanticAnalysis.native_bindings.test.ts | 23 ++++++++++++++++ .../javascriptSemanticMemberMutations.ts | 27 ++++++++++++------- .../javascriptConfigurationReadsCli.test.ts | 21 +++++++++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index e9fed813fc8..d1cfd0fbd7e 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -83,6 +83,11 @@ callee bodies and built-ins such as `Object.assign`, `Reflect.set`, and values. This uncertainty does not prove that a call actually mutated a value. Interprocedural aliasing beyond the supported call-site references remains outside this mutation pass. +For process configuration, only known environment value slots and canonical +argument indexes are treated as primitive when passed to calls. Unknown or +inherited selections can expose a mutable prototype, so later reads of that +configuration container retain uncertainty; earlier top-level reads and unrelated +configuration containers remain independent. Function fingerprints commit normalized syntax, control-flow shape, relation shape, literal sets, arity, and detected effects without using local names or diff --git a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts index 30585330148..6083e2bfe72 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts @@ -225,6 +225,29 @@ describe("JavaScript semantic analysis: configuration value escapes", () => { expect(configurationReads(body)).toEqual([[kind, key]]); }); + it.each([ + "patch(process.argv[key]); return process.argv[3];", + "patch(process.argv.__proto__); return process.argv[3];", + "const args = process.argv; patch(args[key]); return args[3];", + ])( + "retains uncertainty after an object-valued argv selection escapes: %s", + (body) => { + expect(configurationReads(body)).not.toContainEqual(["argv", "3"]); + }, + ); + + it("preserves an earlier top-level read and unrelated configuration after an argv escape", () => { + const captured = analyzeJavaScriptSemantics( + "const saved = process.argv[3]; patch(process.argv[key]); globalThis.saved = saved;", + ); + expect( + captured.configurationOperations.map(({ kind, key }) => [kind, key]), + ).toContainEqual(["argv", "3"]); + expect( + configurationReads("patch(process.argv[key]); return process.env.PORT;"), + ).toContainEqual(["environment", "PORT"]); + }); + it.each([ [ "a direct argv entry", diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 34666e65c73..24535f91eb2 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -69,21 +69,30 @@ export function* collectSemanticMemberMutationsSteps( node: t.Node | undefined, ): void => { const identity = semanticRequireExportsIdentity(specifier, path); - // Node stores process.env values and process.argv entries as strings, so - // passing one to a call exposes no object that the callee could mutate. - // Replacing an entry records its own write or receiver escape. + let effectPath = identity.path; + // Only known value slots are primitive. Unknown or inherited selections + // can expose the container prototype and affect any subsequently read slot. if ( effect === "escape" && identity.specifier === "process" && - identity.path.length === 2 && - (identity.path[0] === "env" || identity.path[0] === "argv") - ) - return; + effectPath.length === 2 && + (effectPath[0] === "env" || effectPath[0] === "argv") + ) { + const selected = effectPath[1]; + if ( + (typeof selected === "string" || typeof selected === "number") && + (effectPath[0] === "env" + ? !Object.hasOwn(Object.prototype, selected) + : selected === "length" || semanticArrayIndex(selected) !== null) + ) + return; + effectPath = [effectPath[0], null]; + } const key = JSON.stringify([ "required", identity.specifier, effect, - identity.path, + effectPath, node?.start, node?.end, ]); @@ -91,7 +100,7 @@ export function* collectSemanticMemberMutationsSteps( recordedRequiredEffects.add(key); recordedEffectCount += 1; const effects = state.requiredModuleEffects.get(identity.specifier) ?? []; - effects.push({ path: identity.path, node, kind: effect }); + effects.push({ path: effectPath, node, kind: effect }); state.requiredModuleEffects.set(identity.specifier, effects); }; // Ambient indexing owns aliases of global objects. Consume its canonical diff --git a/tests/acceptance/applications/javascriptConfigurationReadsCli.test.ts b/tests/acceptance/applications/javascriptConfigurationReadsCli.test.ts index 8b9ba1abc97..db7af4df5cc 100644 --- a/tests/acceptance/applications/javascriptConfigurationReadsCli.test.ts +++ b/tests/acceptance/applications/javascriptConfigurationReadsCli.test.ts @@ -76,3 +76,24 @@ cliTest.for(["CLI", "stdio MCP"] as const)( } }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "does not claim an argv value after an unknown object selection escapes through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-config-escape-"); + await writeFixtureFiles(root, { + "entry.mjs": [ + 'throw new Error("static fixture must never execute");', + "export function run(key) { patch(process.argv[key]); return process.argv[3]; }", + "export function independent() { return process.env.PORT; }", + ].join("\n"), + }); + const graph = (await analyzeJavaScriptDirectory(surface, cli, root)) + .semantic_graph; + const reads = graph.nodes + .filter((node) => node.kind === "config-source") + .map((node) => [node.properties.source_kind, node.properties.key]); + expect(reads).not.toContainEqual(["argv", "3"]); + expect(reads).toContainEqual(["environment", "PORT"]); + }, +); From 9f772cf4d99c340fd50892557d781ccfd109910e Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:44:16 +0800 Subject: [PATCH 21/23] fix(domain): abort rejected JSON before enclosing refinements --- src/domain/jsonValue.test.ts | 12 ++++++++++++ src/domain/jsonValue.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/domain/jsonValue.test.ts b/src/domain/jsonValue.test.ts index f27dc058edb..faca71f535c 100644 --- a/src/domain/jsonValue.test.ts +++ b/src/domain/jsonValue.test.ts @@ -82,6 +82,18 @@ describe("jsonValueSchema depth bound", () => { }, ); + it("stops enclosing refinements before rejected JSON reaches consumers", () => { + let consumed = 0; + const envelope = z.object({ value: jsonValueSchema }).superRefine(() => { + consumed += 1; + }); + const result = envelope.safeParse({ value: deepObject(10_000) }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0]?.message).toContain("maximum nesting depth"); + expect(consumed).toBe(0); + }); + it("rejects cyclic values instead of recursing forever", () => { const cyclic: Record = {}; cyclic.self = cyclic; diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index ef09c385633..021294de480 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -118,7 +118,8 @@ const validatedJsonValue = z .superRefine((value, context) => { const issue = jsonValueValidationIssue(value); if (issue !== undefined) - context.addIssue({ code: "custom", message: issue }); + // Invalid JSON must not reach enclosing semantic/hash refinements. + context.addIssue({ code: "custom", message: issue, continue: false }); }) .transform((value) => value as JsonValue); // Keep both stages nonrecursive in public and Standard Schema projections. From 90020ec14a748dd281c9c107cb2a4f189606a7f6 Mon Sep 17 00:00:00 2001 From: To3akaRin <131595336+To3akaRin@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:44:17 +0800 Subject: [PATCH 22/23] test(ci): synchronize cancellation phases and preserve exact producer bytes --- .../javascriptCancellationCli.test.ts | 32 +++++++++++++++++-- tests/boundary/pcap/pcapProvider.test.ts | 2 +- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/acceptance/applications/javascriptCancellationCli.test.ts b/tests/acceptance/applications/javascriptCancellationCli.test.ts index c270e678c2e..473ae34362c 100644 --- a/tests/acceptance/applications/javascriptCancellationCli.test.ts +++ b/tests/acceptance/applications/javascriptCancellationCli.test.ts @@ -58,13 +58,39 @@ it.skipIf(process.platform === "win32").each([ parsedFiles: 1, }, ])( - "returns typed cancellation and exit 130 after SIGINT through $command during $phase", + "returns typed cancellation and exit 130 through $command during $phase", async ({ command, phase, parsedFiles }) => { const root = await createTestTempDirectory("rea-cli-js-cancellation-"); await writeFile(join(root, "main.js"), "export const observed = 1;\n"); + const nodeArguments: string[] = []; + if (parsedFiles !== null) { + // A tiny graph/hash phase may finish before the parent receives stderr. + // Invoke the real signal handler synchronously in the owned CLI child at + // that progress boundary; the two early-phase cases still use OS SIGINT. + const controls = await createTestTempDirectory("rea-cli-cancel-control-"); + const preload = join(controls, "cancel-at-phase.mjs"); + await writeFile( + preload, + ` + const write = process.stderr.write.bind(process.stderr); + let requested = false; + process.stderr.write = function(chunk, ...args) { + const written = write(chunk, ...args); + let update; + try { update = JSON.parse(String(chunk)); } catch {} + if (!requested && update?.rea_progress?.phase === ${JSON.stringify(phase)}) { + requested = true; + process.emit("SIGINT"); + } + return written; + }; + `, + ); + nodeArguments.push("--import", preload); + } const child = spawn( process.execPath, - ["scripts/rea.mjs", command, root, "--json"], + [...nodeArguments, "scripts/rea.mjs", command, root, "--json"], { stdio: ["ignore", "pipe", "pipe"], }, @@ -95,7 +121,7 @@ it.skipIf(process.platform === "win32").each([ !requested ) { requested = true; - child.kill("SIGINT"); + if (parsedFiles === null) child.kill("SIGINT"); } } }); diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index 0f5f5fbd229..982d10d3854 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -215,7 +215,7 @@ it.each(["capinfos", "tshark"] as const)( reason: "injected retained cleanup failure", previous_error: null, captured_output: { - stdout: stage === "capinfos" ? CAPINFOS_OUTPUT : TSHARK_JSON, + stdout: stage === "capinfos" ? `${CAPINFOS_OUTPUT}\n` : TSHARK_JSON, truncated: false, }, }); From f4c30ba2d0b474a3174d1c407f1766c8792b902b Mon Sep 17 00:00:00 2001 From: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:13:08 +0000 Subject: [PATCH 23/23] fix(browser): preserve uncertainty in network metadata and counts Compare nullable redirect types only where both captures retain them, with completeness owned alongside the shared metadata projection. Hash HTTP rows as they are projected and retain only content digests. Incomplete inventories do not establish duplicate request multiplicity; WebSocket whole-inventory digests also require their own coverage. Keep independently known metadata changes and full-inventory count changes. Cover omissions and positive controls through domain and public CLI/MCP regressions. Give each CLI comparison its own test deadline while reusing one owned MCP connection; preserve existing timeouts and assertions. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/browser-observation.md | 6 + src/domain/webCaptureDiff.network.test.ts | 117 +++++++ src/domain/webCaptureDiff.ts | 182 +++++----- .../tools/passiveNetworkInventory.test.ts | 328 ++++++++++-------- 4 files changed, 410 insertions(+), 223 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index e31789f8878..0bfbb2789f3 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -301,6 +301,12 @@ REA retains local observation data and removes only URL userinfo credentials, ex uses `websocket_connections` and `websocket_frames`. Drops in other event kinds and the aggregate total do not by themselves limit HTTP inventory. Proven modifications remain reportable despite incomplete inventory. + Repeated identical HTTP content samples establish multiplicity changes only + with complete request inventories; limited inventories retain known content + comparisons without interpreting duplicate sample counts as changes. + WebSocket stream inventories and connection counts likewise require complete + WebSocket coverage. Redirect resource types use only shared known metadata: + an omitted type leaves equality unknown rather than proving a modification. - Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded. - WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. diff --git a/src/domain/webCaptureDiff.network.test.ts b/src/domain/webCaptureDiff.network.test.ts index 46c7956a4c3..cf1640a1c01 100644 --- a/src/domain/webCaptureDiff.network.test.ts +++ b/src/domain/webCaptureDiff.network.test.ts @@ -32,7 +32,124 @@ const unselectedShapes = (capture: WebPageInspection): WebPageInspection => { return capture; }; +const redirectCapture = (resourceType: string | null): WebPageInspection => { + const capture = unselectedShapes(passiveNetworkCapture()); + capture.network.requests[0]!.redirects = [ + { + url: "https://app.example.test/start", + response_url: "https://app.example.test/start", + method: "GET", + resource_type: resourceType, + status: 302, + mime_type: "text/html", + encoded_data_length: 20, + request_timestamp: 1, + redirect_event_timestamp: 2, + }, + ]; + return capture; +}; + +describe("nullable redirect resource types", () => { + it.each([true, false])( + "retains uncertainty when a redirect type is omitted (reversed=%s)", + (reversed) => { + const before = redirectCapture("Document"); + const after = structuredClone(before); + after.network.requests[0]!.redirects![0]!.resource_type = null; + const [left, right] = reversed + ? ([after, before] as const) + : ([before, after] as const); + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + }, + ); + + it.each(["Document", "Fetch"])( + "keeps a known redirect type %s comparable", + (resourceType) => { + const before = redirectCapture("Document"); + const after = structuredClone(before); + after.network.requests[0]!.redirects![0]!.resource_type = resourceType; + expect(compare(before, after)).toMatchObject({ + status: resourceType === "Document" ? "unchanged" : "changed", + total_changes: resourceType === "Document" ? 0 : 1, + }); + }, + ); + + it("retains proven HTTP membership with incomplete redirect metadata", () => { + const before = unselectedShapes(passiveNetworkCapture([])); + const after = redirectCapture(null); + expect(compare(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "added" }], + }); + }); +}); + describe("passive network inventory changes", () => { + it.each(["websocket_connections", "websocket_frames"] as const)( + "does not infer duplicate WebSocket inventory changes from dropped %s", + (kind) => { + const before = unselectedShapes(passiveNetworkCapture()); + before.network.websocket_connections = [ + { + request_id: "ws-one", + url: "wss://app.example.test/live", + events: [], + }, + ]; + const after = structuredClone(before); + after.network.websocket_connections.push({ + request_id: "ws-two", + url: "wss://app.example.test/live", + events: [], + }); + const limited = withPassiveDroppedEvents(before, { [kind]: 1 }); + for (const [left, right] of [ + [limited, after], + [after, limited], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + expect(compare(before, after)).toMatchObject({ + status: "changed", + total_changes: 1, + changes: [{ change: "modified" }], + }); + }, + ); + + it.each(["dropped", "truncated", "unavailable"] as const)( + "does not infer repeated-request count changes from %s HTTP inventory", + (limitation) => { + const after = unselectedShapes(passiveNetworkCapture(["/a", "/a"])); + let before = unselectedShapes(passiveNetworkCapture()); + if (limitation === "dropped") + before = withPassiveDroppedEvents(before, { network_requests: 1 }); + else if (limitation === "truncated") + before.completeness.truncated_sections.push("network_requests"); + else before.completeness.unavailable_sections.push("network_requests"); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compare(left, right)).toMatchObject({ + status: "unknown", + total_changes: 0, + changes: [], + }); + }, + ); + it.each(["unavailable", "partial", "truncated"] as const)( "retains complete request additions/removals with %s selected shapes", (coverage) => { diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index e8863798635..de24a0fc3d9 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -304,39 +304,50 @@ const networkResponseFields = [ "mime_type", "encoded_data_length", ] as const; -type NetworkResponseField = (typeof networkResponseFields)[number]; +const redirectMetadataFields = [ + ...networkResponseFields, + "resource_type", +] as const; +type NetworkMetadataField = (typeof redirectMetadataFields)[number]; -const sharedNetworkResponseFields = ( +/** Own both comparable fields and completeness instead of deriving it from set sizes. */ +const sharedNetworkMetadata = ( before: WebPageInspection, after: WebPageInspection, -): ReadonlyMap> => { - const shared = new Map>(); +): { + readonly fields: ReadonlyMap>; + readonly complete: boolean; +} => { + const shared = new Map>(); + let complete = true; + const retainKnownFields = ( + identity: string, + metadata: Pick< + WebPageInspection["network"]["requests"][number], + NetworkMetadataField + >, + required: readonly NetworkMetadataField[], + ): void => { + const fields = shared.get(identity) ?? new Set(required); + for (const field of required) + if (metadata[field] === null) { + fields.delete(field); + complete = false; + } + shared.set(identity, fields); + }; for (const inspection of [before, after]) for (const request of inspection.network.requests) { const identity = networkIdentity(request); - retainSharedResponseFields(shared, identity, request); + retainKnownFields(identity, request, networkResponseFields); for (const [index, redirect] of (request.redirects ?? []).entries()) - retainSharedResponseFields( - shared, + retainKnownFields( redirectNetworkIdentity(identity, index), redirect, + redirectMetadataFields, ); } - return shared; -}; - -const retainSharedResponseFields = ( - shared: Map>, - identity: string, - response: Pick< - WebPageInspection["network"]["requests"][number], - NetworkResponseField - >, -): void => { - const fields = shared.get(identity) ?? new Set(networkResponseFields); - for (const field of networkResponseFields) - if (response[field] === null) fields.delete(field); - shared.set(identity, fields); + return { fields: shared, complete }; }; const redirectNetworkIdentity = (identity: string, index: number): string => @@ -362,20 +373,22 @@ const networkDimension = ( const requestsComplete = [before, after].every((inspection) => networkInventoryComplete(inspection, ["network_requests"]), ); - const requestIdentities = networkIdentityCoverage(before, after); const webSocketInventoryComplete = [before, after].every((inspection) => networkInventoryComplete(inspection, [ "websocket_connections", "websocket_frames", ]), ); - const responseFields = sharedNetworkResponseFields(before, after); - const responseFieldsComplete = [...responseFields.values()].every( - (fields) => fields.size === networkResponseFields.length, - ); + const requestIdentities = networkIdentityCoverage(before, after); + const metadata = sharedNetworkMetadata(before, after); const selected = bodyShapesSelected(before) && bodyShapesSelected(after); const webSocketShapes = webSocketShapesSelected(before) && webSocketShapesSelected(after); + const projection = { + metadataFields: metadata.fields, + ambiguousIdentities: requestIdentities.ambiguousIdentities, + inventoryComplete: requestsComplete, + }; const sources = new Map(); if (selected) { const observedSources = (inspection: WebPageInspection) => { @@ -435,23 +448,17 @@ const networkDimension = ( ); const compared = compareDimension( new Map([ - ...networkMap( - before, - sources, - responseFields, - requestIdentities.ambiguousIdentities, - ), - ...webSocketMetadataMap(beforeWebSockets), + ...networkMap(before, sources, projection), + ...(webSocketInventoryComplete + ? webSocketMetadataMap(beforeWebSockets) + : []), ...beforeWebSocketPayload, ]), new Map([ - ...networkMap( - after, - sources, - responseFields, - requestIdentities.ambiguousIdentities, - ), - ...webSocketMetadataMap(afterWebSockets), + ...networkMap(after, sources, projection), + ...(webSocketInventoryComplete + ? webSocketMetadataMap(afterWebSockets) + : []), ...afterWebSocketPayload, ]), requestsComplete && @@ -470,9 +477,9 @@ const networkDimension = ( ? requestsComplete : identity.startsWith("ws_") && webSocketInventoryComplete, ); - if (responseFieldsComplete) return compared; + if (metadata.complete) return compared; const responseEvidenceReason = - "HTTP response or redirect status, MIME type, or byte-count evidence is incomplete."; + "HTTP response or redirect status, MIME type, byte count, or resource-type evidence is incomplete."; if (compared.status === "changed") return compared; return { status: "unknown", @@ -488,62 +495,71 @@ const networkDimension = ( const networkMap = ( inspection: WebPageInspection, sources: ReadonlyMap, - responseFields: ReadonlyMap>, - ambiguousIdentities: ReadonlySet, + projection: { + readonly metadataFields: ReadonlyMap< + string, + ReadonlySet + >; + readonly ambiguousIdentities: ReadonlySet; + readonly inventoryComplete: boolean; + }, ): ReadonlyMap => { - const grouped = new Map(); + const grouped = new Map(); for (const request of inspection.network.requests) { const identity = networkIdentity(request); // A missing type can move repeated requests between these buckets. Neither // their counts nor fingerprints establish a change to that typed group. - if (ambiguousIdentities.has(identity)) continue; + if (projection.ambiguousIdentities.has(identity)) continue; const shapes = sources.get(identity); const values = grouped.get(identity) ?? []; const response: Record = {}; - for (const field of responseFields.get(identity) ?? []) + for (const field of projection.metadataFields.get(identity) ?? []) response[field] = request[field]; - values.push({ - ...response, - redirects: (request.redirects ?? []).map( - ( - { - request_timestamp: _requestTimestamp, - redirect_event_timestamp: _redirectEventTimestamp, - ...redirect + values.push( + digestCanonicalValue({ + ...response, + redirects: (request.redirects ?? []).map( + ( + { + request_timestamp: _requestTimestamp, + redirect_event_timestamp: _redirectEventTimestamp, + ...redirect + }, + index, + ) => { + const response: Partial< + Record + > = {}; + for (const field of projection.metadataFields.get( + redirectNetworkIdentity(identity, index), + ) ?? []) + if (redirect[field] !== null) response[field] = redirect[field]; + return { + url: redirect.url, + response_url: redirect.response_url, + method: redirect.method, + ...response, + }; }, - index, - ) => { - const response: Partial< - Record - > = {}; - for (const field of responseFields.get( - redirectNetworkIdentity(identity, index), - ) ?? []) - if (redirect[field] !== null) response[field] = redirect[field]; - return { - url: redirect.url, - response_url: redirect.response_url, - method: redirect.method, - resource_type: redirect.resource_type, - ...response, - }; - }, - ), - initiator: request.initiator, - ...(shapes?.request === true - ? { request_shape: request.body_shapes.request } - : {}), - ...(shapes?.response === true - ? { response_shape: request.body_shapes.response } - : {}), - }); + ), + initiator: request.initiator, + ...(shapes?.request === true + ? { request_shape: request.body_shapes.request } + : {}), + ...(shapes?.response === true + ? { response_shape: request.body_shapes.response } + : {}), + }), + ); grouped.set(identity, values); } return new Map( [...grouped].map(([identity, values]) => [ identity, digestCanonicalValue( - values.map((value) => digestCanonicalValue(value)).sort(), + // Repeated samples do not establish multiplicity across omissions; + // keep their known content without interpreting missing rows as change. + (projection.inventoryComplete ? values : [...new Set(values)]).sort(), ), ]), ); diff --git a/tests/acceptance/tools/passiveNetworkInventory.test.ts b/tests/acceptance/tools/passiveNetworkInventory.test.ts index a7812682944..8e5e7a51ff0 100644 --- a/tests/acceptance/tools/passiveNetworkInventory.test.ts +++ b/tests/acceptance/tools/passiveNetworkInventory.test.ts @@ -12,156 +12,204 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; -cliTest.for(["CLI", "stdio MCP"] as const)( - "%s retains passive request inventory changes independently of unknown content", - async (surface, { cli }) => { +const comparisonCases = () => { + const before = passiveNetworkCapture(); + const after = passiveNetworkCapture(["/a", "/b"]); + const incomplete = structuredClone(before); + incomplete.completeness.attach_limited_sections.push("network_requests"); + const wsBefore = structuredClone(before); + const wsAfter = structuredClone(after); + for (const capture of [wsBefore, wsAfter]) + capture.completeness.attach_limited_sections.push( + "websocket_connections", + "websocket_frames", + ); + wsAfter.network.websocket_connections.push({ + request_id: "ws", + url: "wss://app.example.test/live", + events: [], + }); + const repeated = passiveNetworkCapture(["/a", "/a"]); + const repeatedUnknown = structuredClone(repeated); + repeatedUnknown.network.requests[1]!.resource_type = null; + const unknownIdentity = structuredClone(before); + unknownIdentity.network.requests[0]!.resource_type = null; + const unknownIdentityAdded = structuredClone(after); + unknownIdentityAdded.network.requests[0]!.resource_type = null; + const unselectedUnknownIdentity = structuredClone(unknownIdentity); + unselectedUnknownIdentity.completeness.excluded.push({ + section: "json_body_shapes", + reason: "not_approved", + count: null, + }); + unselectedUnknownIdentity.network.requests[0]!.body_shapes.status = + "not_approved"; + const droppedRequest = withPassiveDroppedEvents(before, { + network_requests: 1, + }); + const droppedWebSocket = withPassiveDroppedEvents(before, { + websocket_connections: 1, + websocket_frames: 1, + }); + const unrelatedDrop = withPassiveDroppedEvents(before, { + console_events: 1, + }); + const droppedUnselected = withPassiveDroppedEvents(structuredClone(before), { + network_requests: 1, + }); + droppedUnselected.completeness.excluded.push({ + section: "json_body_shapes", + reason: "not_approved", + count: null, + }); + droppedUnselected.network.requests[0]!.body_shapes.status = "not_approved"; + const droppedRepeated = withPassiveDroppedEvents(before, { + network_requests: 1, + }); + const redirectKnown = structuredClone(before); + redirectKnown.network.requests[0]!.redirects = [ + { + url: "https://app.example.test/start", + response_url: "https://app.example.test/start", + method: "GET", + resource_type: "Document", + status: 302, + mime_type: "text/html", + encoded_data_length: 20, + request_timestamp: 1, + redirect_event_timestamp: 2, + }, + ]; + const redirectUnknown = structuredClone(redirectKnown); + redirectUnknown.network.requests[0]!.redirects![0]!.resource_type = null; + return [ + ["added", before, after, "added"], + ["removed", after, before, "removed"], + ["equal unavailable", before, before, null], + ["incomplete inventory", incomplete, after, null], + ["limited WebSocket inventory", wsBefore, wsAfter, "added"], + ["repeated request type uncertainty", repeated, repeatedUnknown, null], + [ + "repeated request type uncertainty reversed", + repeatedUnknown, + repeated, + null, + ], + ["unknown request identity", before, unknownIdentity, null], + ["unknown request identity reversed", unknownIdentity, before, null], + [ + "unknown identity without selected shapes", + unselectedUnknownIdentity, + unselectedUnknownIdentity, + null, + ], + [ + "different URL beside unknown identity", + before, + unknownIdentityAdded, + "added", + ], + ["dropped request count", droppedRequest, after, null], + ["dropped request count reversed", after, droppedRequest, null], + [ + "dropped request with shapes unselected", + droppedUnselected, + droppedUnselected, + null, + ], + [ + "WebSocket drops do not hide HTTP additions", + droppedWebSocket, + after, + "added", + ], + [ + "unrelated drops do not hide HTTP additions", + unrelatedDrop, + after, + "added", + ], + [ + "duplicate samples with dropped inventory", + droppedRepeated, + repeated, + null, + ], + [ + "duplicate samples with dropped inventory reversed", + repeated, + droppedRepeated, + null, + ], + ["unknown redirect type", redirectKnown, redirectUnknown, null], + ["unknown redirect type reversed", redirectUnknown, redirectKnown, null], + ] as const; +}; + +const cases = comparisonCases().map(([name, left, right, change]) => ({ + name, + left, + right, + change, +})); + +const expectComparison = ( + document: unknown, + name: string, + change: "added" | "removed" | null, +): void => { + const evidence = parseEvidence(document); + expect(evidence.operation).toBe("compare_web_captures"); + const result = webCaptureDiffSchema.parse(evidence.normalized_result); + expect(result.dimensions.network, name).toMatchObject({ + status: change === null ? "unknown" : "changed", + total_changes: change === null ? 0 : 1, + changes: + change === null + ? [] + : [{ identity: expect.stringMatching(/^net_/u), change }], + }); + expect(result.overall_status).toBe(change === null ? "unknown" : "changed"); +}; + +cliTest.for(cases)( + "CLI preserves passive inventory evidence: $name", + async ({ name, left, right, change }, { cli }) => { const root = await createTestTempDirectory("rea-passive-inventory-"); const paths = [ join(root, "before.json"), join(root, "after.json"), ] as const; - const before = passiveNetworkCapture(); - const after = passiveNetworkCapture(["/a", "/b"]); - const incomplete = structuredClone(before); - incomplete.completeness.attach_limited_sections.push("network_requests"); - const wsBefore = structuredClone(before); - const wsAfter = structuredClone(after); - for (const capture of [wsBefore, wsAfter]) - capture.completeness.attach_limited_sections.push( - "websocket_connections", - "websocket_frames", - ); - wsAfter.network.websocket_connections.push({ - request_id: "ws", - url: "wss://app.example.test/live", - events: [], - }); - const repeated = passiveNetworkCapture(["/a", "/a"]); - const repeatedUnknown = structuredClone(repeated); - repeatedUnknown.network.requests[1]!.resource_type = null; - const unknownIdentity = structuredClone(before); - unknownIdentity.network.requests[0]!.resource_type = null; - const unknownIdentityAdded = structuredClone(after); - unknownIdentityAdded.network.requests[0]!.resource_type = null; - const unselectedUnknownIdentity = structuredClone(unknownIdentity); - unselectedUnknownIdentity.completeness.excluded.push({ - section: "json_body_shapes", - reason: "not_approved", - count: null, - }); - unselectedUnknownIdentity.network.requests[0]!.body_shapes.status = - "not_approved"; - const droppedRequest = withPassiveDroppedEvents(before, { - network_requests: 1, - }); - const droppedWebSocket = withPassiveDroppedEvents(before, { - websocket_connections: 1, - websocket_frames: 1, - }); - const unrelatedDrop = withPassiveDroppedEvents(before, { - console_events: 1, - }); - const droppedUnselected = withPassiveDroppedEvents( - structuredClone(before), - { network_requests: 1 }, + const serialized = [ + JSON.stringify({ inspection: left }), + JSON.stringify({ inspection: right }), + ]; + await Promise.all( + paths.map((path, index) => writeFile(path, serialized[index]!)), ); - droppedUnselected.completeness.excluded.push({ - section: "json_body_shapes", - reason: "not_approved", - count: null, + const response = await cli.run({ + arguments: ["compare-web-captures", ...paths, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, }); - droppedUnselected.network.requests[0]!.body_shapes.status = "not_approved"; - const mcp = - surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; - for (const [name, left, right, change] of [ - ["added", before, after, "added"], - ["removed", after, before, "removed"], - ["equal unavailable", before, before, null], - ["incomplete inventory", incomplete, after, null], - ["limited WebSocket inventory", wsBefore, wsAfter, "added"], - ["repeated request type uncertainty", repeated, repeatedUnknown, null], - [ - "repeated request type uncertainty reversed", - repeatedUnknown, - repeated, - null, - ], - ["unknown request identity", before, unknownIdentity, null], - ["unknown request identity reversed", unknownIdentity, before, null], - [ - "unknown identity without selected shapes", - unselectedUnknownIdentity, - unselectedUnknownIdentity, - null, - ], - [ - "different URL beside unknown identity", - before, - unknownIdentityAdded, - "added", - ], - ["dropped request count", droppedRequest, after, null], - ["dropped request count reversed", after, droppedRequest, null], - [ - "dropped request with shapes unselected", - droppedUnselected, - droppedUnselected, - null, - ], - [ - "WebSocket drops do not hide HTTP additions", - droppedWebSocket, - after, - "added", - ], - [ - "unrelated drops do not hide HTTP additions", - unrelatedDrop, - after, - "added", - ], - ] as const) { - const input = { + expect(response.exitCode, response.stderr).toBe(0); + expectComparison(response.json, name, change); + expect( + await Promise.all(paths.map((path) => readFile(path, "utf8"))), + ).toEqual(serialized); + }, +); + +cliTest( + "stdio MCP preserves passive inventory evidence on one owned connection", + async () => { + const mcp = await connectLocalToolsMcp(); + for (const { name, left, right, change } of cases) { + const response = await mcp.call("compare_web_captures", { before: { inspection: left }, after: { inspection: right }, - }; - const serialized = [ - JSON.stringify(input.before), - JSON.stringify(input.after), - ]; - let document: unknown; - if (mcp === undefined) { - await Promise.all( - paths.map((path, index) => writeFile(path, serialized[index]!)), - ); - const response = await cli.run({ - arguments: ["compare-web-captures", ...paths, "--json"], - environment: { REA_LOG_LEVEL: "silent" }, - }); - expect(response.exitCode, response.stderr).toBe(0); - document = response.json; - expect( - await Promise.all(paths.map((path) => readFile(path, "utf8"))), - ).toEqual(serialized); - } else { - const response = await mcp.call("compare_web_captures", input); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - document = response.structuredContent; - } - const evidence = parseEvidence(document); - expect(evidence.operation).toBe("compare_web_captures"); - const result = webCaptureDiffSchema.parse(evidence.normalized_result); - expect(result.dimensions.network, name).toMatchObject({ - status: change === null ? "unknown" : "changed", - total_changes: change === null ? 0 : 1, - changes: - change === null - ? [] - : [{ identity: expect.stringMatching(/^net_/u), change }], }); - expect(result.overall_status).toBe( - change === null ? "unknown" : "changed", - ); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expectComparison(response.structuredContent, name, change); } }, );