diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index ecfed708f9..9a0e90b1fd 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -36,7 +36,9 @@ application starts, and REA never resumes it or sends `Runtime.runIfWaitingForDebugger`. Resume a paused target with a separate caller-owned debugger before requesting application runtime observations. -The caller supplies the literal-loopback HTTP endpoint directly. Selecting an endpoint exposes all targets it serves and their local script locations or renderer origins. A target must match the exact requested target ID and expose a same-port WebSocket reported by that endpoint. Targets already marked attached are rejected rather than displacing another debugger. Local paths are canonicalized after symlink resolution. +The caller supplies the literal-loopback HTTP endpoint directly. Selecting an endpoint exposes all targets it serves and their local script locations or renderer origins. A target must match the exact requested target ID and expose a same-port WebSocket reported by that endpoint. Targets explicitly marked attached are rejected rather than displacing another debugger. Node's `/json/list` omits attachment state, so REA preserves it as `null` (unknown), reports that limitation, and permits observation unless the endpoint explicitly reports `attached: true`. Local paths are canonicalized after symlink resolution. + +The `attached` field in JavaScript runtime and browser target results is nullable: `true` and `false` are reported only when discovery supplied a boolean; `null` means the endpoint omitted or could not report the state. CDP `TargetInfo` worker records require an `attached` boolean; REA excludes a worker record with a missing or malformed value as an invalid protocol value. `list_javascript_runtime_targets` returns every available target in one inline array. diff --git a/scripts/verify/inspector/runtime-observation.mjs b/scripts/verify/inspector/runtime-observation.mjs index 70224312d5..6485ed3cb6 100644 --- a/scripts/verify/inspector/runtime-observation.mjs +++ b/scripts/verify/inspector/runtime-observation.mjs @@ -77,6 +77,8 @@ async function verifyTarget(child, targetPath, name) { assert.equal(discovery.length, 1); const raw = discovery[0]; assert.equal(typeof raw.id, "string"); + const expectedAttached = + typeof raw.attached === "boolean" ? raw.attached : null; // Independent oracle: the fixture owns the pathname, and Node's discovery // serialization loses quotes/backslashes. Never decode underscores into it. const unresolved = process.platform === "win32" || /[_"\\]/u.test(targetPath); @@ -96,6 +98,7 @@ async function verifyTarget(child, targetPath, name) { const inventory = listed.normalized_result; assert.equal(inventory.targets.length, 1); assert.equal(inventory.targets[0].target_id, raw.id); + assert.equal(inventory.targets[0].attached, expectedAttached); assert.deepEqual(inventory.targets[0].location, expectedLocation); assert.equal(inventory.excluded.unsupported_location, 0); const mcpList = await runMcp("list_javascript_runtime_targets", { @@ -122,7 +125,13 @@ async function verifyTarget(child, targetPath, name) { observation_ms: 100, }); for (const result of [observed.normalized_result, mcpObservation]) - verifyObservation(result, raw.id, expectedLocation, targetPath); + verifyObservation( + result, + raw.id, + expectedLocation, + targetPath, + expectedAttached, + ); assert.deepEqual(mcpObservation.target, observed.normalized_result.target); process.stdout.write( `${JSON.stringify({ @@ -133,6 +142,7 @@ async function verifyTarget(child, targetPath, name) { target_id: raw.id, discovery_url: raw.url, target_location: expectedLocation, + attached: inventory.targets[0].attached, cli_scripts: observed.normalized_result.scripts.items.length, mcp_scripts: mcpObservation.scripts.items.length, listed_evidence_id: listed.evidence_id, @@ -141,9 +151,16 @@ async function verifyTarget(child, targetPath, name) { ); } -function verifyObservation(result, targetId, expectedLocation, targetPath) { +function verifyObservation( + result, + targetId, + expectedLocation, + targetPath, + expectedAttached, +) { assert.equal(result.target.target_id, targetId); assert.equal(result.target.runtime_kind, "node"); + assert.equal(result.target.attached, expectedAttached); assert.deepEqual(result.target.location, expectedLocation); assert.equal(result.capture.truncated, false); assert.ok(Array.isArray(result.directly_observed)); diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 642373af56..f4d898a68e 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -107,6 +107,11 @@ export class CdpBrowserProvider implements BrowserObservationPort { : [ `${String(selected.unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`, ]), + ...(selected.allowed.some((target) => target.attached === null) + ? [ + "CDP discovery omitted attachment state for one or more targets; attached is reported as unknown.", + ] + : []), ], }), ); diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index 5aa9d95b87..e1eb616f97 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -57,6 +57,11 @@ export const inspectCdpElectronPage = async ( "Local file paths exposed by the selected CDP target are retained in the observation.", "REA does not evaluate renderer JavaScript, invoke Electron APIs, navigate, click, or close the page.", "Select include_script_sources to retain script contents.", + ...(context.target.attached === null + ? [ + "CDP discovery omitted the target attachment state; attached is reported as unknown.", + ] + : []), ], mainFrameId: undefined, navigationDuringCapture: false, diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index 8257d49748..9cf63a32d9 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -91,6 +91,11 @@ export class CdpElectronProvider implements ElectronObservationPort { : [ `${String(unconnectable)} otherwise allowed page target(s) lacked a validated direct CDP WebSocket and were excluded.`, ]), + ...(allowed.some((target) => target.attached === null) + ? [ + "CDP discovery omitted attachment state for one or more targets; attached is reported as unknown.", + ] + : []), ], }), ); diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index b4e3d102f7..aa254e6d59 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -61,11 +61,15 @@ export const captureElectronWorkers = async (input: { input.completeness.exclude("workers", "invalid_protocol_value"); continue; } + if (typeof target.attached !== "boolean") { + input.completeness.exclude("workers", "invalid_protocol_value"); + continue; + } workers.push({ target_id: targetId, type, file_path: path, - attached: target.attached === true, + attached: target.attached, opener_target_id: openerTargetId, parent_frame_id: parentFrameId, }); diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index 1d1b8c14b8..1c9abfefa1 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -23,7 +23,7 @@ const endpointTargetSchema = z.object({ type: z.string().min(1), title: z.string().default(""), url: z.string(), - attached: z.boolean().default(false), + attached: z.boolean().nullable().optional(), webSocketDebuggerUrl: z.string().min(1).optional(), }); const endpointTargetsSchema = z.array(endpointTargetSchema); @@ -46,7 +46,7 @@ export interface CdpEndpointTarget { readonly type: string; readonly title: string; readonly url: string; - readonly attached: boolean; + readonly attached: boolean | null; readonly webSocket?: CdpPageWebSocketEndpoint; } @@ -106,7 +106,7 @@ export const discoverCdpEndpoint = async ( type: target.type, title: sanitizeTargetTitle(target.title, target.url), url: target.url, - attached: target.attached, + attached: target.attached ?? null, ...targetWebSocket(endpoint, target, operation), })), }; diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 2e3fa11af8..4de472660a 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -84,6 +84,11 @@ export const capturePage = async ( "Raw network headers, bodies, cookies, storage values, console objects, and WebSocket payloads are never retained; explicitly requested primitive console text is retained verbatim, while payload captures retain value-free shapes.", "Source maps are reported only as declarative URLs and are not fetched.", "URL-less scripts and console events without an allowed source URL are excluded because their origin cannot be proven.", + ...(context.target.attached === null + ? [ + "CDP discovery omitted the target attachment state; attached is reported as unknown.", + ] + : []), ], startedAt: new Date().toISOString(), }; diff --git a/src/browser/CdpPageCaptureWorkers.ts b/src/browser/CdpPageCaptureWorkers.ts index eb9c0501f1..3a564c8884 100644 --- a/src/browser/CdpPageCaptureWorkers.ts +++ b/src/browser/CdpPageCaptureWorkers.ts @@ -55,12 +55,16 @@ export const captureWorkers = async ( ); continue; } + if (typeof target.attached !== "boolean") { + completeness.exclude("workers", "invalid_protocol_value"); + continue; + } items.push({ target_id: cdpStringValue(target.targetId) ?? "", type, url: url.url, origin: url.origin, - attached: target.attached === true, + attached: target.attached, opener_target_id: cdpStringValue(target.openerId) ?? null, parent_frame_id: cdpStringValue(target.parentFrameId) ?? null, }); diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index bfa25117b6..e79a903288 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -20,7 +20,12 @@ const browserTargetSchema = z.object({ title: z.string(), url: z.string(), origin: z.string(), - attached: z.boolean(), + attached: z + .boolean() + .nullable() + .describe( + "Discovery-reported debugger attachment state; null means unknown", + ), }); /** Complete policy-filtered browser target discovery result. */ diff --git a/src/domain/javascript/electronObservation.ts b/src/domain/javascript/electronObservation.ts index 339badd254..6258c90c3e 100644 --- a/src/domain/javascript/electronObservation.ts +++ b/src/domain/javascript/electronObservation.ts @@ -43,7 +43,12 @@ const electronTargetSchema = z.object({ type: z.string(), title: z.string(), file_path: z.string(), - attached: z.boolean(), + attached: z + .boolean() + .nullable() + .describe( + "Discovery-reported debugger attachment state; null means unknown", + ), }); /** Complete local file target inventory from one Electron CDP endpoint. */ diff --git a/src/domain/javascript/javascriptRuntimeObservation.ts b/src/domain/javascript/javascriptRuntimeObservation.ts index 41a0c8e431..82efee240c 100644 --- a/src/domain/javascript/javascriptRuntimeObservation.ts +++ b/src/domain/javascript/javascriptRuntimeObservation.ts @@ -89,7 +89,12 @@ export type JavaScriptRuntimeTargetLocation = z.infer< const javascriptRuntimeTargetSchema = z.strictObject({ target_id: z.string().min(1), protocol_type: z.string().min(1), - attached: z.boolean(), + attached: z + .boolean() + .nullable() + .describe( + "Discovery-reported debugger attachment state; null means unknown", + ), location: javascriptRuntimeTargetLocationSchema, }); diff --git a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts index 7b609b9d98..baba3395da 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts @@ -44,21 +44,21 @@ interface NormalizedV8Inspection { readonly target_id: string; readonly type: string; readonly title: string; - readonly attached: boolean; + readonly attached: boolean | null; readonly file_path: string; } | { readonly target_id: string; readonly type: string; readonly title: string; - readonly attached: boolean; + readonly attached: boolean | null; readonly url: string; } | { readonly target_id: string; readonly type: string; readonly title: string; - readonly attached: boolean; + readonly attached: boolean | null; readonly unresolved_location: Extract< JavaScriptRuntimeObservation["target"]["location"], { kind: "unresolved" } diff --git a/src/inspector/V8InspectorCaptureProjection.ts b/src/inspector/V8InspectorCaptureProjection.ts index c1cf384b72..ec2c58e789 100644 --- a/src/inspector/V8InspectorCaptureProjection.ts +++ b/src/inspector/V8InspectorCaptureProjection.ts @@ -26,12 +26,19 @@ export const createInspectorExclusionCounts = (): Record< }); /** Stable safety and protocol limitations shared by target-list Evidence. */ -export const describeInspectorTargetLimitations = (): string[] => [ +export const describeInspectorTargetLimitations = ( + attachmentStateUnknown = false, +): string[] => [ "REA attaches to an already-running exact target and never launches, resumes, evaluates, pauses, or mutates it.", "Only Runtime.enable and Debugger.enable are sent; source text, object values, EventEmitter activity, and Electron IPC are not inspected.", "The selected endpoint and target ID authorize observation; discovery file locations can remain unresolved and do not prove filesystem identity.", "Node discovery reports a best-effort pathname rather than an encoded file URL; lossy Windows paths and ambiguous underscores are preserved without guessing a file path.", "Every target exposed by the selected Inspector endpoint is eligible for observation; target IDs and locations are not authenticated as operating-system process identity.", + ...(attachmentStateUnknown + ? [ + "Inspector discovery did not report the target's current debugger attachment state; it is retained as unknown, and observation rejects only targets explicitly marked attached.", + ] + : []), "No runtime graph depth is traversed because passive Inspector events do not establish require/import caller edges.", ]; @@ -215,7 +222,7 @@ export const finalizeInspectorCapture = async ({ "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.", ], - limitations: describeInspectorTargetLimitations(), + limitations: describeInspectorTargetLimitations(target.attached === null), }; }; diff --git a/src/inspector/V8InspectorEndpoint.ts b/src/inspector/V8InspectorEndpoint.ts index fdb172591d..c6718baf27 100644 --- a/src/inspector/V8InspectorEndpoint.ts +++ b/src/inspector/V8InspectorEndpoint.ts @@ -21,7 +21,7 @@ const targetSchema = z id: z.string().min(1), type: z.string().min(1), url: z.string(), - attached: z.boolean().default(false), + attached: z.boolean().nullable().optional(), webSocketDebuggerUrl: z.string().min(1), }) .passthrough(); @@ -31,7 +31,7 @@ export interface V8InspectorTarget { readonly id: string; readonly type: string; readonly url: string; - readonly attached: boolean; + readonly attached: boolean | null; readonly webSocketUrl: string; } @@ -62,7 +62,7 @@ export const discoverV8Inspector = async ( id: target.id, type: target.type, url: target.url, - attached: target.attached, + attached: target.attached ?? null, webSocketUrl: validatedInspectorWebSocket( endpoint, target.id, diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index 33a0116510..06a413871d 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -117,7 +117,9 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { runtime: discovery.runtime, targets: allowed.map(projectTarget), excluded: { ...excluded, unconnectable: 0 }, - limitations: describeInspectorTargetLimitations(), + limitations: describeInspectorTargetLimitations( + allowed.some((target) => target.attached === null), + ), }), ); } catch (cause: unknown) { @@ -357,7 +359,7 @@ const authorizedTarget = async ( product, }); if ( - target.attached || + target.attached === true || !decision.allowed || decision.location.kind === "builtin" ) diff --git a/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts b/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts index cae84925e1..81fe60759b 100644 --- a/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.targetInventory.test.ts @@ -14,7 +14,7 @@ describe("CDP target inventory", () => { type: "page", title: `Page ${String(index)}`, url: `https://allowed.example/page/${String(index)}`, - attached: false, + ...(index === 0 ? {} : { attached: index === 1 ? false : true }), webSocketDebuggerUrl: `ws://127.0.0.1:${String(port)}/devtools/page/${id}`, }; }); @@ -60,6 +60,14 @@ describe("CDP target inventory", () => { expect(result.value.targets).toHaveLength(targets.length); expect(result.value.targets[0]?.target_id).toBe("target-0000"); expect(result.value.targets.at(-1)?.target_id).toBe("target-1100"); + expect( + result.value.targets.slice(0, 3).map(({ attached }) => attached), + ).toEqual([null, false, true]); + expect( + result.value.limitations.some((item) => + item.includes("attachment state"), + ), + ).toBe(true); } finally { if (server.listening) await new Promise((resolve, reject) => { diff --git a/tests/boundary/browser/cdpElectronProvider.test.ts b/tests/boundary/browser/cdpElectronProvider.test.ts index 8af69a4dad..f115846109 100644 --- a/tests/boundary/browser/cdpElectronProvider.test.ts +++ b/tests/boundary/browser/cdpElectronProvider.test.ts @@ -83,6 +83,7 @@ describe("CdpElectronProvider target selection", () => { const browser = await startFakeCdpBrowser({ electronFileUrl: pathToFileURL(index).href, duplicateElectronInventory: true, + omitElectronTargetAttachmentState: true, }); browsers.push(browser); const provider = new CdpElectronProvider(); @@ -96,8 +97,14 @@ describe("CdpElectronProvider target selection", () => { expect.objectContaining({ target_id: "electron-page", file_path: index, + attached: null, }), ]); + expect( + listed.value.limitations.some((item) => + item.includes("attachment state"), + ), + ).toBe(true); expect(listed.value.excluded).toEqual({ unsupported_url: 3, non_page: 2, @@ -112,6 +119,12 @@ describe("CdpElectronProvider target selection", () => { ); if (!inspected.ok) throw inspected.error; expect(inspected.value.target.file_path).toBe(index); + expect(inspected.value.target.attached).toBeNull(); + expect( + inspected.value.limitations.some((item) => + item.includes("attachment state"), + ), + ).toBe(true); expect(inspected.value.frames).toEqual([ expect.objectContaining({ file_path: index }), ]); diff --git a/tests/boundary/browser/cdpWorkerAttachmentProtocol.test.ts b/tests/boundary/browser/cdpWorkerAttachmentProtocol.test.ts new file mode 100644 index 0000000000..82f0823493 --- /dev/null +++ b/tests/boundary/browser/cdpWorkerAttachmentProtocol.test.ts @@ -0,0 +1,46 @@ +import { expect, it } from "vitest"; + +import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; +import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; + +it.each(["omitted", "null"] as const)( + "excludes TargetInfo workers with %s required attached state", + async (attachmentState) => { + const browser = await startFakeCdpBrowser({ + commandResult: (command, origin) => { + if (command.method !== "Target.getTargets") return undefined; + return { + targetInfos: [ + { + targetId: "worker-1", + type: "service_worker", + url: `${origin}/worker.js`, + openerId: "allowed-page", + ...(attachmentState === "null" ? { attached: null } : {}), + }, + ], + }; + }, + }); + try { + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + expect(result.value.workers).toEqual([]); + expect(result.value.completeness.excluded).toContainEqual({ + section: "workers", + reason: "invalid_protocol_value", + count: 1, + }); + } finally { + await browser.close(); + } + }, +); diff --git a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts index 4dd5612408..293c9aa1f4 100644 --- a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts @@ -235,6 +235,68 @@ test("returns every target exposed by the selected Inspector endpoint inline", a } }); +test.each([ + { label: "omitted", attached: undefined, expected: null }, + { label: "explicitly detached", attached: false, expected: false }, +] as const)( + "preserves $label V8 attachment state through listing and observation", + async ({ attached, expected }) => { + const fixture = await runtimeFixture(); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + ...(attached === undefined ? {} : { attached }), + }); + try { + const provider = new V8InspectorProvider(); + const listed = await provider.listTargets({ + inspector_endpoint: fake.endpoint, + }); + if (!listed.ok) throw listed.error; + expect(listed.value.targets[0]?.attached).toBe(expected); + if (expected === null) + expect( + listed.value.limitations.some((item) => + item.includes("attachment state"), + ), + ).toBe(true); + + const observed = await provider.observe( + observeInput(fake.endpoint, fake.targetId, "node"), + ); + if (!observed.ok) throw observed.error; + expect(observed.value.target.attached).toBe(expected); + if (expected === null) + expect( + observed.value.limitations.some((item) => + item.includes("attachment state"), + ), + ).toBe(true); + } finally { + await fake.close(); + } + }, +); + +test("rejects a V8 target explicitly marked attached", async () => { + const fixture = await runtimeFixture(); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + attached: true, + }); + try { + const result = await new V8InspectorProvider().observe( + observeInput(fake.endpoint, fake.targetId, "node"), + ); + expect(result).toMatchObject({ + ok: false, + error: { reason: "target_not_allowed" }, + }); + expect(fake.commands).toEqual([]); + } finally { + await fake.close(); + } +}); + test("includes targets outside the old caller root filter", async () => { const fixture = await runtimeFixture(); const outside = await temporaryFile("outside.js"); diff --git a/tests/fixtures/fakeCdpBrowserTargets.ts b/tests/fixtures/fakeCdpBrowserTargets.ts index e9725a572a..dd0323fdfe 100644 --- a/tests/fixtures/fakeCdpBrowserTargets.ts +++ b/tests/fixtures/fakeCdpBrowserTargets.ts @@ -101,7 +101,9 @@ export const targets = ( type: "page", title: "Electron application", url: options.electronFileUrl, - attached: false, + ...(options.omitElectronTargetAttachmentState === true + ? {} + : { attached: false }), ...(options.omitTargetWebSocket === true ? {} : { diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index 31b75fc51e..07a882a1e0 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -114,6 +114,8 @@ export interface FakeOptions { readonly webMcpChildRecoversAfterTransient?: boolean; readonly webMcpChildNavigatesAllowed?: boolean; readonly electronFileUrl?: string; + /** Omit attached from the Electron page's HTTP discovery target. */ + readonly omitElectronTargetAttachmentState?: boolean; readonly duplicateElectronInventory?: boolean; readonly electronInventoryCount?: number; readonly urlShapedAllowedTitle?: diff --git a/tests/fixtures/inspector/fakeV8Inspector.ts b/tests/fixtures/inspector/fakeV8Inspector.ts index b853227aa1..9852f14296 100644 --- a/tests/fixtures/inspector/fakeV8Inspector.ts +++ b/tests/fixtures/inspector/fakeV8Inspector.ts @@ -10,6 +10,7 @@ interface InspectorCommand { interface FakeV8InspectorOptions { readonly targetUrl: string; + readonly attached?: boolean | null; readonly targetType?: "node" | "page"; readonly runtimeProduct?: string; readonly scriptUrls?: readonly string[]; @@ -60,21 +61,19 @@ export const startFakeV8Inspector = async ( if (request.url === "/json/list") { response.end( JSON.stringify([ - target( - targetId, - options.targetUrl, - options.targetType ?? "node", + target(targetId, options.targetUrl, { + type: options.targetType ?? "node", port, - ), + attached: options.attached, + }), ...(options.additionalTargetUrl === undefined ? [] : [ - target( - otherTargetId, - options.additionalTargetUrl, - options.targetType ?? "node", + target(otherTargetId, options.additionalTargetUrl, { + type: options.targetType ?? "node", port, - ), + attached: options.attached, + }), ]), ...Array.from( { length: options.additionalTargetCount ?? 0 }, @@ -82,8 +81,11 @@ export const startFakeV8Inspector = async ( target( `00000000-0000-4000-8000-${String(index + 3).padStart(12, "0")}`, options.targetUrl, - options.targetType ?? "node", - port, + { + type: options.targetType ?? "node", + port, + attached: options.attached, + }, ), ), ]), @@ -220,15 +222,18 @@ const sendScriptEvents = ( const target = ( id: string, url: string, - type: "node" | "page", - port: number, + options: { + readonly type: "node" | "page"; + readonly port: number; + readonly attached: boolean | null | undefined; + }, ) => ({ id, - type, + type: options.type, title: "redacted-by-provider", url, - attached: false, - webSocketDebuggerUrl: `ws://localhost:${String(port)}/${id}`, + ...(options.attached === undefined ? {} : { attached: options.attached }), + webSocketDebuggerUrl: `ws://localhost:${String(options.port)}/${id}`, }); const parseCommand = (raw: string): InspectorCommand => {