fix(inspector): retain observations after capture failures
This commit is contained in:
parent
c267e99227
commit
dea3498b2b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -47,11 +47,6 @@ export const authorizeRuntimeLocation = async (
|
|||
signal?: AbortSignal,
|
||||
): Promise<RuntimeLocationDecision> => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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<typeof authorizeRuntimeLocation>;
|
||||
readonly locationMode?: "verify" | "reported";
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
|
||||
|
|
@ -60,29 +62,32 @@ export const finalizeInspectorCapture = async ({
|
|||
target,
|
||||
state,
|
||||
authorizeLocation = authorizeRuntimeLocation,
|
||||
locationMode = "verify",
|
||||
signal,
|
||||
}: FinalizeCaptureInput): Promise<JavaScriptRuntimeObservation> => {
|
||||
throwIfRuntimeObservationCancelled(signal);
|
||||
if (locationMode === "verify") throwIfRuntimeObservationCancelled(signal);
|
||||
const exclusions = createInspectorExclusionCounts();
|
||||
const scripts = new Map<
|
||||
string,
|
||||
JavaScriptRuntimeObservation["scripts"]["items"][number]
|
||||
>();
|
||||
const draftsByUrl = new Map<string, ScriptDraft[]>();
|
||||
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<void> => {
|
||||
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",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void>((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<void>((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<void>((resolve) => sockets.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -130,20 +130,32 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
|
|||
options: ExecutionOptions = {},
|
||||
): Promise<Result<JavaScriptRuntimeObservation, AnalysisError>> {
|
||||
let connection: CdpConnection | undefined;
|
||||
let discovery: Awaited<ReturnType<typeof discoverV8Inspector>> | undefined;
|
||||
let target: AuthorizedV8InspectorTarget | undefined;
|
||||
let state: CaptureState | undefined;
|
||||
let primaryFailure: unknown;
|
||||
let failed = false;
|
||||
let cleanupFailure: unknown;
|
||||
let cleanupFailed = false;
|
||||
let connectionClosePromise: Promise<void> | undefined;
|
||||
let outcome:
|
||||
| Result<JavaScriptRuntimeObservation, AnalysisError>
|
||||
| undefined;
|
||||
const closeConnection = (): Promise<void> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue