diff --git a/src/domain/javascript/javascriptRuntimeObservation.ts b/src/domain/javascript/javascriptRuntimeObservation.ts index 662561778..41a0c8e43 100644 --- a/src/domain/javascript/javascriptRuntimeObservation.ts +++ b/src/domain/javascript/javascriptRuntimeObservation.ts @@ -27,7 +27,6 @@ const observedJavaScriptRuntimeKindSchema = javascriptRuntimeKindSchema.or( z.literal("unknown"), ); -/** Input for one bounded, attach-only V8 Inspector observation. */ /** Input for one passive attach-only V8 Inspector observation. */ export const observeJavaScriptRuntimeInputSchema = z.strictObject({ ...runtimeEndpoint, @@ -49,7 +48,6 @@ export const javascriptRuntimeLocationSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("file"), file_path: z.string().min(1), - authority: z.literal("scope-fallback").optional(), }), z.strictObject({ kind: z.literal("url"), @@ -68,8 +66,15 @@ export type JavaScriptRuntimeLocation = z.infer< /** Discovery metadata can identify an endpoint target without verifying its file location. */ export const javascriptRuntimeUnresolvedLocationSchema = z.strictObject({ kind: z.literal("unresolved"), - reported_url: z.string().min(1), - reason: z.literal("unverifiable-file-location"), + reported_url: z + .string() + .describe( + "Raw Inspector URL, which may be empty for anonymous or evaluated code.", + ), + reason: z.enum([ + "unverifiable-file-location", + "location-authorization-not-attempted", + ]), }); /** Verified locations and explicitly unresolved discovery metadata. */ @@ -104,7 +109,7 @@ export type JavaScriptRuntimeTargetList = z.infer< const javascriptRuntimeScriptSchema = z.strictObject({ script_key: prefixedDigestSchema("v8_script"), - location: javascriptRuntimeLocationSchema, + location: javascriptRuntimeTargetLocationSchema, execution_context_key: z.string().nullable(), cdp_hash: z.string().nullable(), length: z diff --git a/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts b/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts index f8500e892..a339f7db6 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationParsing.ts @@ -20,7 +20,7 @@ import { javascriptRuntimeObservationSchema, javascriptRuntimeKindSchema, type JavaScriptRuntimeObservation, - type JavaScriptRuntimeLocation, + type JavaScriptRuntimeTargetLocation, } from "./javascriptRuntimeObservation.js"; import { canonicalDigest } from "../comparisonSemantics.js"; import { compareCodePoints } from "../canonicalOrdering.js"; @@ -89,7 +89,16 @@ interface NormalizedV8Inspection { language: null; source: { readonly included: false; readonly reason: string }; }> & - ({ readonly file_path: string } | { readonly url: string }))[]; + ( + | { readonly file_path: string } + | { readonly url: string } + | { + readonly unresolved_location: Extract< + JavaScriptRuntimeObservation["scripts"]["items"][number]["location"], + { kind: "unresolved" } + >; + } + ))[]; }; readonly workers: readonly []; readonly completeness: BrowserCompleteness; @@ -367,8 +376,17 @@ const normalizeV8Inspection = ( }); const runtimeLocation = ( - location: JavaScriptRuntimeLocation, -): { readonly file_path: string } | { readonly url: string } => { + location: JavaScriptRuntimeTargetLocation, +): + | { readonly file_path: string } + | { readonly url: string } + | { + readonly unresolved_location: Extract< + JavaScriptRuntimeTargetLocation, + { kind: "unresolved" } + >; + } => { + if (location.kind === "unresolved") return { unresolved_location: location }; if (location.kind === "file") return { file_path: location.file_path }; if (location.kind === "url") return { url: location.sanitized_url }; return { url: location.specifier }; diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index 925807eb1..7d9cfd42c 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -144,9 +144,14 @@ const scriptEntities = ( ): RuntimeReconciliationEntity[] => capture.inspection.scripts.items.map((script) => { const location = - "url" in script - ? ({ kind: "url", value: script.url } as const) - : ({ kind: "file", value: script.file_path } as const); + "unresolved_location" in script + ? ({ + kind: "unresolved", + value: script.unresolved_location.reported_url, + } as const) + : "url" in script + ? ({ kind: "url", value: script.url } as const) + : ({ kind: "file", value: script.file_path } as const); return runtimeEntity(capture, { kind: "script", nodeKind: "runtime-script-instance", @@ -161,7 +166,13 @@ const scriptEntities = ( : null, label: location.value, properties: { - location: location.value, + ...("unresolved_location" in script + ? { + location_kind: "unresolved", + reported_url: script.unresolved_location.reported_url, + location_reason: script.unresolved_location.reason, + } + : { location: location.value }), cdp_hash: script.cdp_hash, length: script.length, is_module: script.is_module, diff --git a/src/inspector/JavaScriptRuntimeScope.ts b/src/inspector/JavaScriptRuntimeScope.ts index 47e501f0c..db8c13ec0 100644 --- a/src/inspector/JavaScriptRuntimeScope.ts +++ b/src/inspector/JavaScriptRuntimeScope.ts @@ -47,11 +47,6 @@ export const authorizeRuntimeLocation = async ( signal?: AbortSignal, ): Promise => { throwIfRuntimeObservationCancelled(signal); - if (value.startsWith("node:") && value.length > 0) - return { - allowed: true, - location: { kind: "builtin", specifier: value }, - }; if (value.startsWith("file:")) { const filePath = await authorizedElectronFile(value); throwIfRuntimeObservationCancelled(signal); @@ -59,6 +54,18 @@ export const authorizeRuntimeLocation = async ( ? { allowed: false, reason: "not_approved" } : { allowed: true, location: { kind: "file", file_path: filePath } }; } + return projectReportedRuntimeUrl(value); +}; + +/** Project non-file Inspector URLs without filesystem access. */ +export const projectReportedRuntimeUrl = ( + value: string, +): RuntimeLocationDecision => { + if (value.startsWith("node:") && value.length > 0) + return { + allowed: true, + location: { kind: "builtin", specifier: value }, + }; let url: URL; try { url = new URL(value); diff --git a/src/inspector/V8InspectorCaptureProjection.test.ts b/src/inspector/V8InspectorCaptureProjection.test.ts index 4a6eb6922..61bfa5c4b 100644 --- a/src/inspector/V8InspectorCaptureProjection.test.ts +++ b/src/inspector/V8InspectorCaptureProjection.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { afterAll, beforeAll, expect, it } from "vitest"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { finalizeInspectorCapture } from "./V8InspectorCaptureProjection.js"; @@ -159,6 +159,108 @@ it("authorizes each unique script location through a bounded worker pool", async }); }); +it("preserves raw script facts without authorizing locations in partial projection", async () => { + const state = stateFor([ + { + rawUrl: "file:///slow-or-unavailable.js?token=retained", + executionContextKey: "1", + cdpHash: "observed", + length: 7, + isModule: false, + }, + { + rawUrl: "https://example.test/script.js?caller-value=kept", + executionContextKey: "1", + cdpHash: "remote", + length: 12, + isModule: true, + }, + { + rawUrl: "", + executionContextKey: "1", + cdpHash: "anonymous", + length: 0, + isModule: false, + }, + ]); + state.eventsObserved += 1; + state.eventsRetained += 1; + state.contexts.set("1", { + contextKey: "1", + state: "created", + name: "main", + origin: "file:///slow-or-unavailable.js?token=retained", + }); + const authorizeLocation = vi.fn(async () => { + throw new Error("must not run after cancellation"); + }); + + const result = await finalizeInspectorCapture({ + input: { + inspector_endpoint: "http://127.0.0.1:9222", + target_id: target.id, + observation_ms: 100, + }, + runtime: { + product: "Node.js/v24.18.0", + protocol_version: "1.3", + v8_version: null, + }, + target, + state, + locationMode: "reported", + authorizeLocation, + }); + + expect(authorizeLocation).not.toHaveBeenCalled(); + expect(result.capture.events_observed).toBe(4); + expect(result.capture.events_retained).toBe(4); + expect(result.scripts.items).toHaveLength(3); + expect( + result.scripts.items.find(({ cdp_hash }) => cdp_hash === "observed"), + ).toMatchObject({ + location: { + kind: "unresolved", + reported_url: "file:///slow-or-unavailable.js?token=retained", + reason: "location-authorization-not-attempted", + }, + length: 7, + }); + expect( + result.scripts.items.find(({ cdp_hash }) => cdp_hash === "remote"), + ).toMatchObject({ + location: { + kind: "url", + origin: "https://example.test", + sanitized_url: "https://example.test/script.js?caller-value=kept", + }, + length: 12, + is_module: true, + }); + expect( + result.scripts.items.find(({ cdp_hash }) => cdp_hash === "anonymous"), + ).toMatchObject({ + location: { + kind: "unresolved", + reported_url: "", + reason: "location-authorization-not-attempted", + }, + length: 0, + }); + expect(result.scripts.observed_total).toBe(3); + expect(result.execution_contexts).toEqual([ + { + context_key: "1", + state: "created", + name: "main", + origin: "file:///slow-or-unavailable.js?token=retained", + }, + ]); + expect(result.unknowns).toContain( + "2 observed script locations remain unverified; raw URLs and script metadata are retained.", + ); +}); + it("waits for active authorization workers to settle before returning a failure", async () => { const scripts = Array.from({ length: 12 }, (_, index) => ({ rawUrl: `file:///fixture/failure-${String(index)}.js`, diff --git a/src/inspector/V8InspectorCaptureProjection.ts b/src/inspector/V8InspectorCaptureProjection.ts index 0b274d760..c1cf384b7 100644 --- a/src/inspector/V8InspectorCaptureProjection.ts +++ b/src/inspector/V8InspectorCaptureProjection.ts @@ -1,14 +1,15 @@ import { createHash } from "node:crypto"; import type { - JavaScriptRuntimeLocation, JavaScriptRuntimeObservation, + JavaScriptRuntimeTargetLocation, JavaScriptRuntimeTargetList, ObserveJavaScriptRuntimeInput, } from "../domain/javascript/javascriptRuntimeObservation.js"; import { authorizeRuntimeLocation, inspectorExclusionKey, + projectReportedRuntimeUrl, throwIfRuntimeObservationCancelled, } from "./JavaScriptRuntimeScope.js"; import type { CaptureState, ScriptDraft } from "./V8InspectorProvider.js"; @@ -43,6 +44,7 @@ interface FinalizeCaptureInput { rawUrl: string, signal?: AbortSignal, ) => ReturnType; + readonly locationMode?: "verify" | "reported"; readonly signal?: AbortSignal; } @@ -60,29 +62,32 @@ export const finalizeInspectorCapture = async ({ target, state, authorizeLocation = authorizeRuntimeLocation, + locationMode = "verify", signal, }: FinalizeCaptureInput): Promise => { - throwIfRuntimeObservationCancelled(signal); + if (locationMode === "verify") throwIfRuntimeObservationCancelled(signal); const exclusions = createInspectorExclusionCounts(); const scripts = new Map< string, JavaScriptRuntimeObservation["scripts"]["items"][number] >(); const draftsByUrl = new Map(); - for (const draft of state.scripts) { - const drafts = draftsByUrl.get(draft.rawUrl) ?? []; - drafts.push(draft); - draftsByUrl.set(draft.rawUrl, drafts); - } + if (locationMode === "verify") + for (const draft of state.scripts) { + const drafts = draftsByUrl.get(draft.rawUrl) ?? []; + drafts.push(draft); + draftsByUrl.set(draft.rawUrl, drafts); + } const groups = [...draftsByUrl]; const authorizedGroups: AuthorizedLocationGroup[] = new Array(groups.length); let nextGroup = 0; let stopScheduling = false; + let unresolvedScripts = 0; const worker = async (): Promise => { try { for (;;) { - throwIfRuntimeObservationCancelled(signal); if (stopScheduling) return; + throwIfRuntimeObservationCancelled(signal); const index = nextGroup; nextGroup += 1; const group = groups[index]; @@ -99,18 +104,23 @@ export const finalizeInspectorCapture = async ({ throw cause; } }; - const workerResults = await Promise.allSettled( - Array.from( - { length: Math.min(LOCATION_AUTHORIZATION_WORKERS, groups.length) }, - worker, - ), - ); + const workerResults = + locationMode === "verify" + ? await Promise.allSettled( + Array.from( + { length: Math.min(LOCATION_AUTHORIZATION_WORKERS, groups.length) }, + worker, + ), + ) + : []; const failedWorker = workerResults.find( (result) => result.status === "rejected", ); - throwIfRuntimeObservationCancelled(signal); + if (locationMode === "verify") throwIfRuntimeObservationCancelled(signal); if (failedWorker?.status === "rejected") throw failedWorker.reason; - for (const { drafts, decision } of authorizedGroups) { + for (const group of authorizedGroups) { + if (group === undefined) continue; + const { drafts, decision } = group; for (const draft of drafts) { if (!decision.allowed) { exclusions[inspectorExclusionKey(decision.reason)] += 1; @@ -120,6 +130,13 @@ export const finalizeInspectorCapture = async ({ scripts.set(script.script_key, script); } } + if (locationMode === "reported") + for (const draft of state.scripts) { + const location = partialScriptLocation(draft.rawUrl); + if (location.kind === "unresolved") unresolvedScripts += 1; + const script = scriptFromDraft(draft, location); + scripts.set(script.script_key, script); + } const items = [...scripts.values()].sort((left, right) => left.script_key < right.script_key ? -1 @@ -141,7 +158,7 @@ export const finalizeInspectorCapture = async ({ ? 1 : 0, ); - throwIfRuntimeObservationCancelled(signal); + if (locationMode === "verify") throwIfRuntimeObservationCancelled(signal); return { runtime, target: { @@ -189,6 +206,11 @@ export const finalizeInspectorCapture = async ({ "The discovery-reported file location cannot be verified; loaded script locations are resolved independently from Debugger.scriptParsed.", ] : []), + ...(unresolvedScripts === 0 + ? [] + : [ + `${String(unresolvedScripts)} observed script location${unresolvedScripts === 1 ? " remains" : "s remain"} unverified; raw URLs and script metadata are retained.`, + ]), "Scripts collected before attachment may have been garbage-collected and therefore omitted.", "A bounded observation window cannot establish that an unobserved script or behavior never occurs.", "The declared Node/Electron process role is not authenticated by the Inspector protocol.", @@ -199,7 +221,7 @@ export const finalizeInspectorCapture = async ({ const scriptFromDraft = ( draft: ScriptDraft, - location: JavaScriptRuntimeLocation, + location: JavaScriptRuntimeTargetLocation, ): JavaScriptRuntimeObservation["scripts"]["items"][number] => { const stable = JSON.stringify({ location, @@ -218,3 +240,15 @@ const scriptFromDraft = ( status: "observed-loaded", }; }; + +const partialScriptLocation = ( + reportedUrl: string, +): JavaScriptRuntimeTargetLocation => { + const projection = projectReportedRuntimeUrl(reportedUrl); + if (projection.allowed) return projection.location; + return { + kind: "unresolved", + reported_url: reportedUrl, + reason: "location-authorization-not-attempted", + }; +}; diff --git a/src/inspector/V8InspectorProvider.partial.test.ts b/src/inspector/V8InspectorProvider.partial.test.ts new file mode 100644 index 000000000..0ee635249 --- /dev/null +++ b/src/inspector/V8InspectorProvider.partial.test.ts @@ -0,0 +1,175 @@ +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { expect, it } from "vitest"; +import { WebSocketServer } from "ws"; +import { z } from "zod"; + +import { safeParseJson } from "../domain/safeJson.js"; +import { V8InspectorProvider } from "./V8InspectorProvider.js"; + +const failureCases = [ + ["disconnect", "disconnected"], + ["protocol failure", "protocol_error"], + ["cancellation", "cancelled"], +] as const; + +it.each(failureCases)( + "preserves captured scripts and contexts after %s", + async (failure, reason) => { + const root = await mkdtemp(join(tmpdir(), "rea-inspector-partial-")); + const scriptPath = join(root, "target.js"); + const scriptUrl = pathToFileURL(scriptPath).href; + const reportedScriptUrl = failure === "cancellation" ? "" : scriptUrl; + await writeFile(scriptPath, "globalThis.ready = true;\n"); + + let signalScriptSent: (() => void) | undefined; + const scriptSent = new Promise((resolve) => { + signalScriptSent = resolve; + }); + const server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/json/version") { + response.end( + JSON.stringify({ + Browser: "Node.js/v24.18.0", + "Protocol-Version": "1.3", + "V8-Version": "12.0", + }), + ); + return; + } + if (request.url === "/json/list") { + const address = server.address(); + if (address === null || typeof address === "string") { + response.writeHead(500).end(); + return; + } + response.end( + JSON.stringify([ + { + id: "target-1", + type: "node", + url: scriptUrl, + attached: false, + webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/target-1`, + }, + ]), + ); + return; + } + response.writeHead(404).end(); + }); + const sockets = new WebSocketServer({ server, path: "/target-1" }); + sockets.on("connection", (socket) => { + socket.on("message", (raw) => { + const parsed = safeParseJson(raw.toString()); + if (!parsed.ok) throw parsed.cause; + const command = z + .object({ id: z.number(), method: z.string() }) + .parse(parsed.value); + if (command.method === "Runtime.enable") { + socket.send( + JSON.stringify({ + method: "Runtime.executionContextCreated", + params: { + context: { id: 1, name: "main", origin: reportedScriptUrl }, + }, + }), + ); + socket.send( + JSON.stringify({ + method: "Debugger.scriptParsed", + params: { + scriptId: "1", + url: reportedScriptUrl, + executionContextId: 1, + hash: "captured-before-failure", + length: 24, + isModule: false, + }, + }), + ); + socket.send(JSON.stringify({ id: command.id, result: {} })); + return; + } + if (command.method === "Debugger.enable") { + // Receiving the next command proves the preceding events and reply + // were consumed; cancellation needs no timing assumption. + signalScriptSent?.(); + if (failure === "protocol failure") { + socket.send( + JSON.stringify({ id: command.id, result: "malformed" }), + ); + } else { + socket.send(JSON.stringify({ id: command.id, result: {} })); + if (failure === "disconnect") socket.close(); + } + return; + } + socket.send(JSON.stringify({ id: command.id, result: {} })); + }); + }); + + try { + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Inspector fixture did not bind a TCP port"); + + const controller = new AbortController(); + const executionOptions = + failure === "cancellation" ? { signal: controller.signal } : {}; + const observing = new V8InspectorProvider().observe( + { + inspector_endpoint: `http://127.0.0.1:${String(address.port)}`, + target_id: "target-1", + observation_ms: 10_000, + }, + executionOptions, + ); + if (failure === "cancellation") { + await scriptSent; + controller.abort(); + } + const result = await observing; + + expect(result.ok).toBe(false); + if (result.ok) + throw new Error("Expected the Inspector operation to fail"); + expect(result.error).toMatchObject({ + _tag: "BrowserObservationError", + reason, + partialObservation: { + capture: { events_observed: 2 }, + scripts: { + observed_total: 1, + items: [ + { + location: { + kind: "unresolved", + reported_url: reportedScriptUrl, + reason: "location-authorization-not-attempted", + }, + cdp_hash: "captured-before-failure", + length: 24, + }, + ], + }, + execution_contexts: [ + { context_key: "1", name: "main", origin: reportedScriptUrl }, + ], + }, + }); + } finally { + await new Promise((resolve) => sockets.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index d7a56b4c9..78a22fe10 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -130,20 +130,32 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { options: ExecutionOptions = {}, ): Promise> { let connection: CdpConnection | undefined; + let discovery: Awaited> | undefined; + let target: AuthorizedV8InspectorTarget | undefined; + let state: CaptureState | undefined; let primaryFailure: unknown; let failed = false; let cleanupFailure: unknown; let cleanupFailed = false; + let connectionClosePromise: Promise | undefined; let outcome: | Result | undefined; + const closeConnection = (): Promise => { + if (connection === undefined) return Promise.resolve(); + connectionClosePromise ??= closeInspectorConnection( + connection, + options.signal, + ); + return connectionClosePromise; + }; try { - const discovery = await discoverV8Inspector( + discovery = await discoverV8Inspector( input.inspector_endpoint, "observe_javascript_runtime", options.signal, ); - const target = await authorizedTarget( + target = await authorizedTarget( discovery.targets, input, discovery.runtime.product, @@ -156,9 +168,10 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { options.signal, { maxPayloadBytes: INSPECTOR_MAX_PAYLOAD_BYTES }, ); - const state = emptyCaptureState(); + const captureState = emptyCaptureState(); + state = captureState; const removeListener = connection.onEvent((event) => - ingestEvent(event, state), + ingestEvent(event, captureState), ); try { await connection.send("Runtime.enable", {}, undefined, options.signal); @@ -171,18 +184,71 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { input, runtime: discovery.runtime, target, - state, + state: captureState, ...(options.signal === undefined ? {} : { signal: options.signal }), }); outcome = ok(javascriptRuntimeObservationSchema.parse(result)); } catch (cause: unknown) { failed = true; primaryFailure = cause; - outcome = err(providerError(cause, "observe_javascript_runtime")); + let error = providerError(cause, "observe_javascript_runtime"); + if ( + state !== undefined && + discovery !== undefined && + target !== undefined && + (cause instanceof BrowserObservationError || + options.signal?.aborted === true) + ) { + const reason = + options.signal?.aborted === true + ? "cancelled" + : cause instanceof BrowserObservationError + ? cause.reason + : "protocol_error"; + try { + await closeConnection(); + } catch (cause: unknown) { + cleanupFailed = true; + cleanupFailure = cause; + } + try { + const partialObservation = await finalizeInspectorCapture({ + input, + runtime: discovery.runtime, + target, + state, + locationMode: "reported", + }); + error = new BrowserObservationError( + "observe_javascript_runtime", + reason, + { + cause: error, + ...(error.userMessage === undefined + ? {} + : { detail: error.userMessage }), + partialObservation, + }, + ); + } catch (projectionFailure: unknown) { + error = new BrowserObservationError( + "observe_javascript_runtime", + reason, + { + cause: new AggregateError( + [error, projectionFailure], + "Inspector failure and partial-observation projection both failed", + ), + detail: `${error.message} Partial observations could not be projected.`, + }, + ); + } + } + outcome = err(error); } finally { if (connection !== undefined) try { - await closeInspectorConnection(connection, options.signal); + await closeConnection(); } catch (cause: unknown) { cleanupFailed = true; cleanupFailure = cause; @@ -356,7 +422,7 @@ const ingestScript = (event: CdpEvent, state: CaptureState): void => { state.scriptsObserved += 1; const value = recordValue(event.params); const rawUrl = cdpStringValue(value?.url); - if (rawUrl === undefined || rawUrl === "") { + if (rawUrl === undefined) { state.invalidScripts += 1; retainEvent(state, 0); return; diff --git a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts index 8849bc136..149d6e833 100644 --- a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts @@ -475,6 +475,75 @@ describe("passive V8 Inspector evidence", () => { expect(result.summary.runtime_scripts).toBe(1); expect(result.summary.matched).toBeGreaterThan(0); }); + + test("keeps unverified partial script URLs unresolved during reconciliation", () => { + const rawUrl = "file:///tmp/not-verified.js?caller-value=kept"; + const resolvedObservation = runtimeObservation( + "/Applications/Example.app/Contents/Resources/app/index.html", + "/Applications/Example.app/Contents/Resources/app/renderer.js", + ); + const resolvedScript = resolvedObservation.scripts.items[0]; + if (resolvedScript === undefined) + throw new Error("Expected the runtime fixture to have one script"); + const observation: JavaScriptRuntimeObservation = { + ...resolvedObservation, + scripts: { + ...resolvedObservation.scripts, + items: [ + ...resolvedObservation.scripts.items.map((script) => ({ + ...script, + location: { + kind: "unresolved" as const, + reported_url: rawUrl, + reason: "location-authorization-not-attempted" as const, + }, + })), + { + ...resolvedScript, + script_key: `v8_script_${"5".repeat(64)}`, + location: { + kind: "unresolved", + reported_url: "", + reason: "location-authorization-not-attempted", + }, + cdp_hash: null, + length: 0, + }, + ], + }, + }; + const runtimeEvidence = createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "example-v8-target", + runtime_kind: "electron-main", + observation_ms: 100, + }, + observation, + V8_INSPECTOR_PROVIDER_IDENTITY, + ); + + const reconciled = reconcileJavaScriptRuntimeEvidence({ + static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: [runtimeEvidence], + }); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.normalized_result, + ); + expect(result.summary.runtime_scripts).toBe(2); + expect(result.reconciliations).toContainEqual( + expect.objectContaining({ + entity_kind: "script", + status: "unknown", + reason: "runtime-location-unresolved", + }), + ); + expect(JSON.stringify(result.graph)).toContain(rawUrl); + expect(JSON.stringify(result.graph)).toContain("v8-hash"); + expect(JSON.stringify(result.graph)).toContain('"reported_url":""'); + }); }); const runtimeFixture = async () => {