diff --git a/docs/browser-observation.md b/docs/browser-observation.md index f841dfc606..6c2c04a02a 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -12,6 +12,13 @@ For request/response bodies and traffic debugging, see [Fiddler Everywhere captures and MCP](web-network-captures.md#reading-fiddler-captures-saz). Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate MCP server. +HTTP discovery and browser WebSocket inputs require an explicit port. An +explicit default `:80` remains valid and is retained in the canonical endpoint; +omitting the port remains invalid. HTTP `/json/list` discovery preserves usable +siblings when optional metadata is malformed: attachment becomes unknown and +row/field diagnostics appear in `limitations`. Target identity and socket +authority are validated independently before selection and connection. + ## Shipped surfaces - `list_browser_targets` / `rea list-browser-targets` discovers all eligible page targets at the endpoint by default, with an optional exact-origin filter. diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index 44cc589204..36f319399b 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -44,11 +44,19 @@ launch does not, by itself, show that fuse. 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. +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. HTTP `/json/list` discovery parses target rows independently. Malformed optional attachment metadata becomes `null` with a row and field diagnostic in `limitations`; usable siblings remain available. Rows with malformed target identity are excluded with diagnostics, and socket authority is still validated before connection. 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. +Attachment refusals identify `attached: true` and ask the caller to disconnect +the other debugger or select another target. A declared-role mismatch names +`runtime_kind` and the target protocol type; correct the role or omit it to +retain an unknown role. A role-only disagreement in supplied runtime Evidence +is attributed to `parameters.runtime_kind`, separately from a target-ID +disagreement. Partial-capture warnings count retained distinct script records; +`observed_total` retains the raw script-event count before deduplication. + For the MCP follow-up call, pass the same literal-loopback endpoint and the selected target ID to `observe_javascript_runtime`. REA rediscovers targets and validates the ID against the live endpoint before observation. ### Discovery file locations diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 124104c0ce..19de49dd10 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -151,6 +151,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { targets: selected.allowed, excluded: selected.excluded, limitations: [ + ...(discovery.limitations ?? []), input.allowed_origins.length === 0 ? "All page targets with supported HTTP(S) URLs are listed." : "Only page targets whose current URL matches an explicitly requested exact origin are listed.", diff --git a/src/browser/CdpDiscoveryTargets.ts b/src/browser/CdpDiscoveryTargets.ts new file mode 100644 index 0000000000..1e0967d475 --- /dev/null +++ b/src/browser/CdpDiscoveryTargets.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +import { BrowserObservationError } from "../domain/browserObservationError.js"; +import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; + +const identitySchema = z.object({ + id: z.string().min(1), + type: z.string().min(1), + url: z.string(), + title: z.unknown().optional(), + attached: z.unknown().optional(), + webSocketDebuggerUrl: z.unknown().optional(), +}); + +/** Shared HTTP /json/list facts; socket authority stays with each transport owner. */ +export interface CdpDiscoveryTarget { + readonly id: string; + readonly type: string; + readonly url: string; + readonly title: string; + readonly attached: boolean | null; + readonly webSocketDebuggerUrl: unknown; +} + +/** A malformed optional field cannot erase valid sibling target identities. */ +export const parseCdpDiscoveryTargets = ( + input: unknown, + operation: BrowserObservationOperation, +): { + readonly targets: readonly CdpDiscoveryTarget[]; + readonly limitations: readonly string[]; +} => { + if (!Array.isArray(input)) + throw new BrowserObservationError(operation, "invalid_endpoint_response", { + detail: "CDP /json/list must return an array of target rows.", + }); + const targets: CdpDiscoveryTarget[] = []; + const limitations: string[] = []; + for (const [index, row] of input.entries()) { + const parsed = identitySchema.safeParse(row); + if (!parsed.success) { + limitations.push( + `CDP /json/list row ${String(index)} was excluded because its target identity is malformed: ${JSON.stringify(parsed.error.issues)}.`, + ); + continue; + } + const target = parsed.data; + if ( + target.attached !== undefined && + target.attached !== null && + typeof target.attached !== "boolean" + ) + limitations.push( + `CDP /json/list row ${String(index)} target ${target.id} reported malformed attached metadata ${JSON.stringify(target.attached)}; attachment state is unknown.`, + ); + if (target.title !== undefined && typeof target.title !== "string") + limitations.push( + `CDP /json/list row ${String(index)} target ${target.id} reported malformed title metadata ${JSON.stringify(target.title)}; title is unavailable.`, + ); + targets.push({ + id: target.id, + type: target.type, + url: target.url, + title: typeof target.title === "string" ? target.title : "", + attached: typeof target.attached === "boolean" ? target.attached : null, + webSocketDebuggerUrl: target.webSocketDebuggerUrl, + }); + } + return { targets, limitations }; +}; diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index ddf09e83f8..1f23a62117 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -94,6 +94,7 @@ export const inspectCdpElectronPage = async ( authorizedFrameGenerations: new Set(), scriptGenerations: new WeakMap(), limitations: [ + ...(context.discovery.limitations ?? []), "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.", diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index e0e20cc91b..7924c61749 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -197,6 +197,7 @@ export class CdpElectronProvider implements ElectronObservationPort { non_page: nonPage, }, limitations: [ + ...(discovery.limitations ?? []), "Every local file page exposed by the selected loopback CDP endpoint is eligible for listing.", ...(unconnectable === 0 ? [] diff --git a/src/browser/CdpEndpoint.test.ts b/src/browser/CdpEndpoint.test.ts index 46f7eaeca1..ea9e8776e4 100644 --- a/src/browser/CdpEndpoint.test.ts +++ b/src/browser/CdpEndpoint.test.ts @@ -43,6 +43,79 @@ afterEach(async () => { ); }); +it("retains usable CDP sibling identities and reports malformed optional discovery metadata", async () => { + const { endpoint } = await listen((request, response) => { + const port = new URL(endpoint).port; + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify( + request.url === "/json/version" + ? { + Browser: "Chrome/fixture", + "Protocol-Version": "1.3", + "User-Agent": "fixture", + "V8-Version": "fixture", + "WebKit-Version": "fixture", + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/browser/fixture`, + } + : [ + { + id: "malformed-attachment", + type: "page", + url: "https://example.test/a", + attached: "unknown", + }, + { + id: "selected", + type: "page", + url: "https://example.test/b", + attached: false, + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/selected`, + }, + { id: 1, type: "page", url: "https://example.test/invalid" }, + { + id: "invalid-socket", + type: "page", + url: "https://example.test/c", + attached: null, + webSocketDebuggerUrl: 1, + }, + { + id: "worker", + type: "worker", + url: "https://example.test/worker", + attached: true, + }, + ], + ), + ); + }); + const discovery = await discoverCdpEndpoint(endpoint, "list_browser_targets"); + expect(discovery.targets.map(({ id }) => id)).toEqual([ + "malformed-attachment", + "selected", + "invalid-socket", + "worker", + ]); + expect(discovery.targets[0]?.attached).toBeNull(); + expect(discovery.targets[1]?.webSocket).toMatchObject({ + scope: "page", + targetId: "selected", + }); + expect(discovery.targets[2]?.webSocket).toBeUndefined(); + expect(discovery.targets[3]?.attached).toBe(true); + expect(discovery.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining( + 'target malformed-attachment reported malformed attached metadata "unknown"', + ), + expect.stringContaining( + "row 2 was excluded because its target identity is malformed", + ), + ]), + ); +}); + describe("CDP discovery cancellation ownership", () => { it("preserves the exact reason for a pre-aborted discovery request", async () => { let requests = 0; diff --git a/src/browser/CdpEndpoint.ts b/src/browser/CdpEndpoint.ts index 20ed279409..c6012fd5d7 100644 --- a/src/browser/CdpEndpoint.ts +++ b/src/browser/CdpEndpoint.ts @@ -16,6 +16,10 @@ import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; import { safeParseJson } from "../domain/safeJson.js"; import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; import type { CdpConnection } from "./CdpConnection.js"; +import { + parseCdpDiscoveryTargets, + type CdpDiscoveryTarget, +} from "./CdpDiscoveryTargets.js"; const endpointVersionSchema = z.object({ Browser: z.string().min(1), @@ -26,16 +30,6 @@ const endpointVersionSchema = z.object({ webSocketDebuggerUrl: z.string().min(1), }); -const endpointTargetSchema = z.object({ - id: z.string().min(1), - type: z.string().min(1), - title: z.string().default(""), - url: z.string(), - attached: z.boolean().nullable().optional(), - webSocketDebuggerUrl: z.string().min(1).optional(), -}); -const endpointTargetsSchema = z.array(endpointTargetSchema); - /** Per-response bounds for loopback CDP discovery documents. There is no runtime override. */ const CDP_DISCOVERY_RESPONSE_LIMITS = { bytes: 8 * 1024 * 1024, @@ -114,6 +108,7 @@ export interface CdpEndpointTarget { } export interface CdpEndpointDiscovery { + readonly limitations?: readonly string[]; readonly webSocket: CdpWebSocketEndpoint; readonly version: { readonly product: string; @@ -146,12 +141,12 @@ export const discoverCdpEndpoint = async ( versionInput, operation, ); - const targets = parseCdpEndpointValue( - endpointTargetsSchema, + const { targets, limitations } = parseCdpDiscoveryTargets( targetsInput, operation, ); return { + limitations, webSocket: safeCdpWebSocketEndpoint( endpoint, version.webSocketDebuggerUrl, @@ -435,10 +430,14 @@ const pathIdentifier = ( const targetWebSocket = ( endpoint: string, - target: z.infer, + target: CdpDiscoveryTarget, operation: BrowserObservationOperation, ): { readonly webSocket?: CdpPageWebSocketEndpoint } => { - if (target.webSocketDebuggerUrl === undefined) return {}; + if ( + typeof target.webSocketDebuggerUrl !== "string" || + target.webSocketDebuggerUrl.length === 0 + ) + return {}; try { const webSocket = safeCdpWebSocketEndpoint( endpoint, diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index a95602af1f..a23c0102b9 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -158,6 +158,7 @@ export const observeCdpSession = async ( timeline: capture.timeline, completeness: capture.completeness.snapshot(), limitations: [ + ...(context.discovery.limitations ?? []), "The timeline begins after REA attaches; earlier navigation and network activity is unavailable.", "Only navigation metadata is retained; headers, bodies, cookies, console values, and payloads are not captured by this tool.", "An out-of-policy destination ends collection immediately and is recorded without its URL.", diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 7c1de584aa..b4efc8be5d 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -85,6 +85,7 @@ export const capturePage = async ( events, allowedOrigins, limitations: [ + ...(context.discovery.limitations ?? []), "Observation starts when REA attaches; prior network and console activity is unavailable.", "Worker targets are a final inventory snapshot, not worker-session observation. Their attached flag describes any debugging client; this page-session collector does not observe worker network, console, or script events, and short-lived workers may be absent from the snapshot.", "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.", diff --git a/src/browser/execution/CdpEventListeners.ts b/src/browser/execution/CdpEventListeners.ts index 62f42e1382..eda55ed113 100644 --- a/src/browser/execution/CdpEventListeners.ts +++ b/src/browser/execution/CdpEventListeners.ts @@ -94,6 +94,7 @@ export const captureWebEventListeners = async ( sources: retained, cleanup: "confirmed", limitations: [ + ...(session.discovery.limitations ?? []), "Inspects listeners registered directly on the first native CSS match in the selected main document. Delegated ancestor listeners, descendants, closed shadow roots and framework registries are not enumerated.", "Native CDP listener descriptors report callback locations; registration time, dispatch history and UI causality remain unknown. REA does not dispatch an event, evaluate JavaScript or invoke a handler.", "Source identity uses this session's script ID and proven main-frame default-world metadata. Isolated or unknown worlds cannot establish website callback/source joins. A URL or sourceURL declaration never establishes identity or execution.", diff --git a/src/browser/execution/CdpExecutionCapture.ts b/src/browser/execution/CdpExecutionCapture.ts index 1448ac2296..691d37d209 100644 --- a/src/browser/execution/CdpExecutionCapture.ts +++ b/src/browser/execution/CdpExecutionCapture.ts @@ -154,6 +154,7 @@ export const captureWebExecution = async ( page_ownership: "external", }, limitations: [ + ...(session.discovery.limitations ?? []), "Website source ownership requires the selected main frame and a proven default execution world. Isolated, worker and unknown worlds are excluded from coverage/source joins; referenced producer context metadata is retained.", "Precise coverage resets counters, retains execution records and prevents optimized execution until stopped. Timing and JIT behavior are affected.", "Only the selected main document's proven frame/context scripts are attributed. Child frames, workers, service workers, WebAssembly and scripts without ownership metadata remain outside this operation.", diff --git a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts index 66d93b65b5..eb96918ac0 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts @@ -292,14 +292,18 @@ const assertV8RuntimeParameters = ( evidence.parameters, [...path, "parameters"], ); - if ( - parameters.target_id !== result.target.target_id || - (parameters.runtime_kind !== undefined && - parameters.runtime_kind !== result.target.runtime_kind) - ) + if (parameters.target_id !== result.target.target_id) throw invalidInput( [...path, "parameters", "target_id"], - "Runtime Evidence target or declared role disagrees with its result", + "Runtime Evidence target disagrees with its result", + ); + if ( + parameters.runtime_kind !== undefined && + parameters.runtime_kind !== result.target.runtime_kind + ) + throw invalidInput( + [...path, "parameters", "runtime_kind"], + "Runtime Evidence declared role disagrees with its result", ); if ( result.target.location.kind === "builtin" && diff --git a/src/inspector/V8InspectorCaptureProjection.test.ts b/src/inspector/V8InspectorCaptureProjection.test.ts index 3b263f7430..ccfe9c4710 100644 --- a/src/inspector/V8InspectorCaptureProjection.test.ts +++ b/src/inspector/V8InspectorCaptureProjection.test.ts @@ -271,6 +271,32 @@ it("preserves raw script facts without authorizing locations in partial projecti ); }); +it("counts retained unresolved script locations after deduplication in partial captures", async () => { + const draft = { + rawUrl: "file:///fixture/duplicate.js", + executionContextKey: "1", + cdpHash: "same", + length: 7, + isModule: false, + }; + const result = await finalizeInspectorCapture({ + input: { + inspector_endpoint: "http://127.0.0.1:9222", + target_id: target.id, + observation_ms: 0, + }, + runtime: { product: "Node.js", protocol_version: "1.3", v8_version: null }, + target, + state: stateFor([draft, { ...draft }]), + locationMode: "reported", + }); + expect(result.scripts.items).toHaveLength(1); + expect(result.scripts.observed_total).toBe(2); + expect(result.unknowns).toContain( + "1 observed script location remains 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 ec3eab0789..916477de96 100644 --- a/src/inspector/V8InspectorCaptureProjection.ts +++ b/src/inspector/V8InspectorCaptureProjection.ts @@ -44,6 +44,7 @@ export const describeInspectorTargetLimitations = ( ]; interface FinalizeCaptureInput { + readonly discoveryLimitations?: readonly string[]; readonly input: ObserveJavaScriptRuntimeInput; readonly runtime: JavaScriptRuntimeTargetList["runtime"]; readonly target: AuthorizedV8InspectorTarget; @@ -65,6 +66,7 @@ interface AuthorizedLocationGroup { /** Canonically authorize, deduplicate, and sort one bounded raw capture. */ export const finalizeInspectorCapture = async ({ + discoveryLimitations = [], input, runtime, target, @@ -90,7 +92,6 @@ export const finalizeInspectorCapture = async ({ const authorizedGroups: AuthorizedLocationGroup[] = new Array(groups.length); let nextGroup = 0; let stopScheduling = false; - let unresolvedScripts = 0; const worker = async (): Promise => { try { for (;;) { @@ -141,7 +142,6 @@ export const finalizeInspectorCapture = async ({ 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); } @@ -152,6 +152,9 @@ export const finalizeInspectorCapture = async ({ ? 1 : 0, ); + const unresolvedScripts = items.filter( + ({ location }) => location.kind === "unresolved", + ).length; const contexts = [...state.contexts.values()] .map((context) => ({ context_key: context.contextKey, @@ -225,7 +228,10 @@ 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(target.attached === null), + limitations: [ + ...discoveryLimitations, + ...describeInspectorTargetLimitations(target.attached === null), + ], }; }; diff --git a/src/inspector/V8InspectorEndpoint.test.ts b/src/inspector/V8InspectorEndpoint.test.ts index 83a0d20fcb..c99fba45ea 100644 --- a/src/inspector/V8InspectorEndpoint.test.ts +++ b/src/inspector/V8InspectorEndpoint.test.ts @@ -3,6 +3,89 @@ import { createServer } from "node:http"; import { expect, it } from "vitest"; import { V8InspectorProvider } from "./V8InspectorProvider.js"; +import { startFakeV8Inspector } from "../../tests/fixtures/inspector/fakeV8Inspector.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; + +it("observes a target with malformed optional attachment metadata as explicitly unknown", async () => { + const inspector = await startFakeV8Inspector({ + targetUrl: "https://example.test/entry.js", + attached: "unknown", + }); + try { + const provider = new V8InspectorProvider(); + const listed = await provider.listTargets({ + inspector_endpoint: inspector.endpoint, + }); + expect(listed).toMatchObject({ + ok: true, + value: { + targets: [{ attached: null }], + limitations: expect.arrayContaining([ + expect.stringContaining('malformed attached metadata "unknown"'), + ]), + }, + }); + const result = await provider.observe({ + inspector_endpoint: inspector.endpoint, + target_id: inspector.targetId, + runtime_kind: "node", + observation_ms: 0, + }); + expect(result).toMatchObject({ + ok: true, + value: { + target: { attached: null }, + limitations: expect.arrayContaining([ + expect.stringContaining('malformed attached metadata "unknown"'), + ]), + }, + }); + } finally { + await inspector.close(); + } +}); + +it.each([ + { + attached: true, + kind: "node" as const, + message: "attached: true", + action: "Disconnect the other debugger", + }, + { + attached: false, + kind: "electron-renderer" as const, + message: "runtime_kind electron-renderer", + action: "Correct runtime_kind", + }, +])( + "explains Inspector admission refusals before opening a socket: $message", + async ({ attached, kind, message, action }) => { + const inspector = await startFakeV8Inspector({ + targetUrl: "https://example.test/entry.js", + attached, + }); + try { + const result = await new V8InspectorProvider().observe({ + inspector_endpoint: inspector.endpoint, + target_id: inspector.targetId, + runtime_kind: kind, + observation_ms: 0, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected Inspector admission refusal"); + const diagnostic = projectAnalysisError(result.error); + expect(diagnostic).toMatchObject({ + code: "target_unavailable", + message: expect.stringContaining(message), + }); + expect(JSON.stringify(diagnostic)).toContain(action); + expect(inspector.commands).toEqual([]); + } finally { + await inspector.close(); + } + }, +); it("keeps connectable siblings when a target omits its direct socket", async () => { const server = createServer((request, response) => { diff --git a/src/inspector/V8InspectorEndpoint.ts b/src/inspector/V8InspectorEndpoint.ts index a3dcd2357e..59e8a15b7a 100644 --- a/src/inspector/V8InspectorEndpoint.ts +++ b/src/inspector/V8InspectorEndpoint.ts @@ -8,6 +8,7 @@ import { BrowserObservationError } from "../domain/browserObservationError.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; import { parseCdpEndpointValue, readCdpJson } from "../browser/CdpEndpoint.js"; +import { parseCdpDiscoveryTargets } from "../browser/CdpDiscoveryTargets.js"; const versionSchema = z .object({ @@ -17,20 +18,6 @@ const versionSchema = z }) .passthrough(); -const targetSchema = z - .object({ - id: z.string().min(1), - type: z.string().min(1), - url: z.string(), - attached: z.boolean().nullable().optional(), - // Node may publish anonymous/evaluated targets without a direct socket. - // Keep malformed values local to their target so one row cannot discard - // otherwise usable siblings from the endpoint inventory. - webSocketDebuggerUrl: z.unknown().optional(), - }) - .passthrough(); -const targetsSchema = z.array(targetSchema); - export interface V8InspectorTarget { readonly id: string; readonly type: string; @@ -40,6 +27,7 @@ export interface V8InspectorTarget { } export interface V8InspectorDiscovery { + readonly limitations: readonly string[]; readonly runtime: JavaScriptRuntimeTargetList["runtime"]; readonly targets: readonly V8InspectorTarget[]; } @@ -55,8 +43,12 @@ export const discoverV8Inspector = async ( readCdpJson(new URL("/json/list", endpoint), operation, signal), ]); const version = parseCdpEndpointValue(versionSchema, versionInput, operation); - const targets = parseCdpEndpointValue(targetsSchema, targetsInput, operation); + const { targets, limitations } = parseCdpDiscoveryTargets( + targetsInput, + operation, + ); return { + limitations, runtime: { product: version.Browser, protocol_version: version["Protocol-Version"], diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index ca556289ba..7a72aaac7b 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -275,17 +275,23 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { runtime: discovery.runtime, targets: allowed.map(projectTarget), excluded: { ...excluded, unconnectable }, - limitations: describeInspectorTargetLimitations( - allowed.some((target) => target.attached === null), - ), + limitations: [ + ...discovery.limitations, + ...describeInspectorTargetLimitations( + allowed.some((target) => target.attached === null), + ), + ], }), ); } catch (cause: unknown) { if (cause instanceof AnalysisCancelledError && discovery !== undefined) { const complete = targetsExamined === discovery.targets.length; - const limitations = describeInspectorTargetLimitations( - allowed.some((target) => target.attached === null), - ); + const limitations = [ + ...discovery.limitations, + ...describeInspectorTargetLimitations( + allowed.some((target) => target.attached === null), + ), + ]; return err( new AnalysisCancelledError("list_javascript_runtime_targets", { cause: cause.cause, @@ -390,6 +396,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { const result = await finalizeInspectorCapture({ input, runtime: discovery.runtime, + discoveryLimitations: discovery.limitations, target, state: captureState, authorizeLocation: (rawUrl, signal) => @@ -415,6 +422,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { const partialObservation = await finalizeInspectorCapture({ input, runtime: discovery.runtime, + discoveryLimitations: discovery.limitations, target, state, locationMode: "reported", @@ -538,14 +546,26 @@ const authorizedTarget = async ( asarHeaderAdmission, ); throwIfRuntimeObservationCancelled(signal); - if ( - target.attached === true || - !decision.allowed || - decision.location.kind === "builtin" - ) + if (target.attached === true) throw new BrowserObservationError( "observe_javascript_runtime", "target_not_allowed", + { + detail: `Inspector target ${target.id} reports attached: true; REA will not displace its debugger.`, + diagnostics: { target_id: target.id, attached: true }, + remediationAction: + "Disconnect the other debugger or select an unattached target, refresh list_javascript_runtime_targets, and retry with its exact target ID.", + }, + ); + if (!decision.allowed || decision.location.kind === "builtin") + throw new BrowserObservationError( + "observe_javascript_runtime", + "target_not_allowed", + { + detail: `Inspector target ${target.id} has no supported observable target location.`, + remediationAction: + "Select a target with a supported file or HTTP(S) location from list_javascript_runtime_targets.", + }, ); if (target.webSocketUrl === null) throw new BrowserObservationError( @@ -578,6 +598,16 @@ const assertRuntimeKind = ( throw new BrowserObservationError( "observe_javascript_runtime", "target_not_allowed", + { + detail: `Declared runtime_kind ${String(kind)} is incompatible with Inspector target ${target.id} protocol type ${target.type}.`, + diagnostics: { + target_id: target.id, + protocol_type: target.type, + runtime_kind: kind ?? null, + }, + remediationAction: + "Correct runtime_kind to match the selected target's protocol type (node for Node or Electron main, page for Electron renderer or preload), or omit runtime_kind to retain an unknown role. The declared role is not authenticated by Inspector.", + }, ); }; diff --git a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts index 859334de2e..5def338696 100644 --- a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts +++ b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts @@ -16,6 +16,10 @@ import { observeJavaScriptRuntimeInputSchema, } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; import { createServer } from "../../../src/server/createServer.js"; +import { JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE } from "../../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/inspector/providerIdentity.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -33,6 +37,88 @@ describe("JavaScript runtime observation MCP tools", () => { ); }); + test("attributes V8 role and target disagreements to the parameter that differs", async () => { + const session = createTestBinarySession(() => { + throw new Error("No provider expected"); + }); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "runtime-parameter-test", version: "1" }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + resources.push(client, server, session); + await server.connect(st); + await client.connect(ct); + const observation = javascriptRuntimeObservationSchema.parse({ + runtime: { + product: "node.js", + protocol_version: "1.3", + v8_version: null, + }, + target: { + target_id: "example-v8-target", + protocol_type: "node", + attached: false, + location: { kind: "file", file_path: "/fixtures/main.js" }, + runtime_kind: "electron-main", + runtime_kind_authority: "caller-declared-unverified", + }, + capture: { + observation_ms: 0, + events_observed: 0, + events_retained: 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: [], + observed_total: 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: [], + unavailable_without_instrumentation: [], + unknowns: [], + limitations: [], + }); + for (const [target_id, runtime_kind, expectedPath] of [ + ["example-v8-target", "node", "runtime_kind"], + ["different-target", "electron-main", "target_id"], + ["example-v8-target", "electron-main", null], + ] as const) { + const evidence = createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id, + runtime_kind, + observation_ms: 0, + }, + observation, + V8_INSPECTOR_PROVIDER_IDENTITY, + ); + const response = await client.callTool({ + name: "reconcile_javascript_runtime", + arguments: { + static_layers: + JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: [evidence], + }, + }); + if (expectedPath === null) expect(response.isError).not.toBe(true); + else + expect(parseMcpToolError(response).error.details).toMatchObject({ + issues: [ + { + path: ["runtime_observations", 0, "parameters", expectedPath], + reason: "invalid_value", + }, + ], + }); + } + await client.ping(); + }); + test("accepts long caller windows and complete observation metadata", () => { const repeated = (count: number): string[] => Array.from({ length: count }, (_, index) => `item-${index}`); diff --git a/tests/fixtures/inspector/fakeV8Inspector.ts b/tests/fixtures/inspector/fakeV8Inspector.ts index c9e3f69150..35ef3cb69a 100644 --- a/tests/fixtures/inspector/fakeV8Inspector.ts +++ b/tests/fixtures/inspector/fakeV8Inspector.ts @@ -10,7 +10,7 @@ interface InspectorCommand { interface FakeV8InspectorOptions { readonly targetUrl: string; - readonly attached?: boolean | null; + readonly attached?: unknown; readonly targetType?: "node" | "page"; readonly runtimeProduct?: string; readonly scriptUrls?: readonly string[]; @@ -227,7 +227,7 @@ const target = ( options: { readonly type: "node" | "page"; readonly port: number; - readonly attached: boolean | null | undefined; + readonly attached: unknown; }, ) => ({ id,