diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index fe67f90aa8..be8656e8a4 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 8aeee7e9a7..f9f97d3750 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 2ffd8e31fd..877bab06d1 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 0000000000..6e83147277 --- /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, + ); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 6d30a10f52..b92aa990f6 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 f7c4a8e7ae..e582f07a85 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/javascriptSemanticAnalysis.native_bindings.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts index 784a5a5b6f..3058533014 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/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index f3d7063b76..34666e65c7 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/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index dedf27bd42..9f4abfb52d 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/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 466a09ad56..a1ba9fc0ce 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", diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index b8358ec476..63ad24b629 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 ed1ca8d169..eefa8a7f95 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 957cd49c26..4e7cb7e115 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 6a980aa7ad..75b54e26e4 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/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index 69eca33b84..d6192e2e69 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"); }); diff --git a/tests/boundary/cli/developmentTests.test.ts b/tests/boundary/cli/developmentTests.test.ts index d9b9fa7f18..3976781d5a 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 () => { diff --git a/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts b/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts index 0ca2e791ae..852275c2c0 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); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 0490ae2116..8387acdaae 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('""'), ]); }); diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index 69a5af6621..d9d4c8ffb5 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 diff --git a/tests/boundary/filesystem/productCatalog.test.ts b/tests/boundary/filesystem/productCatalog.test.ts index cabf725709..ecdf5e5123 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"', diff --git a/tests/boundary/mcp/browserComparisonInput.test.ts b/tests/boundary/mcp/browserComparisonInput.test.ts index 0dba6faffb..7e916c9f53 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,24 @@ describe("browser comparison input boundary", () => { }, }, ]; - // The projection leaves group exclusion to the canonical runtime parser. - const mixedGroups = [{ ...passive, ...scenario }]; + // Strict clients reject combinators and dependentRequired, so the + // projection states the groups in its description and leaves group + // completeness and exclusion to the canonical runtime parser. + expect(advertised.inputSchema.description).toContain( + "before + after; before_scenario + after_scenario + [normalization]", + ); + const mixedGroups = [ + { ...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, diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index f4915afd13..f9d9afcd59 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -91,7 +91,9 @@ describe("enhanced MCP tools", () => { }, }); }); +}); +describe("enhanced MCP graph caveats", () => { it("keeps generic provider caveats visible without truncating complete relationships", async () => { const caveat = "Decompiler output may omit indirect calls."; const client = await connect({ @@ -113,21 +115,33 @@ describe("enhanced MCP tools", () => { arguments: { address: "0x1", direction: "forward" }, }); expect(graph.structuredContent).toMatchObject({ + limitations: [ + "Artifact identity is unavailable for this observation.", + "Derived by an REA composed workflow.", + caveat, + ], normalized_result: { graph: { "0": [ - { status: "ok", relationship_coverage: { status: "complete" } }, + { + address: "0x1", + calls: ["0x2"], + status: "ok", + relationship_coverage: { status: "complete" }, + }, ], "1": [ - { status: "ok", relationship_coverage: { status: "complete" } }, + { + address: "0x2", + calls: [], + status: "ok", + relationship_coverage: { status: "complete" }, + }, ], }, limitations: [caveat], }, }); - expect(JSON.stringify(graph.structuredContent)?.split(caveat)).toHaveLength( - 2, - ); const trace = await client.callTool({ name: "trace_call_path", diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 9b80722e7d..09ad518ab2 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 3d96ddb67b..391a7a47bd 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({ - ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, - }); + const started = await client.start(); expect(launcher.processes).toHaveLength(1); + expect(started).toEqual({ + ok: true, + value: { + name: "REA Hopper bridge", + version: "1.0.0", + processId: launcher.processes[0]?.pid, + }, + }); await expect( client.callTool("echo", { value: "retried" }), ).resolves.toEqual({ diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index 55fdf0ec9e..00f02d6ecd 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),