mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(inspector): preserve discovery uncertainty and correction paths
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
? []
|
||||
|
||||
@@ -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;
|
||||
|
||||
+13
-14
@@ -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<typeof endpointTargetSchema>,
|
||||
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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<void> => {
|
||||
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),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user