fix(inspector): preserve lossy Node discovery locations (#542)
Keep explicitly selected Node Inspector targets observable when discovery cannot verify their file location. Resolve literal POSIX discovery paths without URI decoding, and retain lossy Windows or underscore paths as unresolved metadata. Validate scriptParsed file URLs independently. Preserve unknown target identity through runtime reconciliation, regenerate contracts, and verify real CLI/MCP behavior on Linux and Windows. Closes #530 Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
1 parent
05bb630a00
commit
84e4361c09
22 files changed
+654
-106
No files matched your search
@@ -281,10 +281,14 @@ jobs:
|
||||
retention-days: 30
|
||||
|
||||
javascript-runtime-observation:
|
||||
name: JavaScript runtime observation
|
||||
name: JavaScript runtime observation (${{ matrix.os }})
|
||||
needs: changes
|
||||
if: github.event_name == 'push' || needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -27,6 +27,38 @@ array.
|
||||
|
||||
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
|
||||
|
||||
Node's `/json/list` reports a best-effort pathname prefixed with `file://`,
|
||||
without URI encoding. On POSIX, REA interprets unambiguous paths literally,
|
||||
including `#` and `%` in filenames. Node also replaces backslashes and double
|
||||
quotes with underscores. That transformation cannot be reversed reliably:
|
||||
an underscore may already be part of the original filename.
|
||||
|
||||
Windows paths, ambiguous underscore paths, and local discovery paths that
|
||||
cannot be verified remain eligible by their endpoint and exact target ID.
|
||||
Their target location preserves the reported value:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "unresolved",
|
||||
"reported_url": "file://C:_tools_entry.js",
|
||||
"reason": "unverifiable-file-location"
|
||||
}
|
||||
```
|
||||
|
||||
This location does not claim that a file exists or authorize reading its
|
||||
contents. Listing remains HTTP-only. Observation resolves loaded script
|
||||
locations independently from `Debugger.scriptParsed` file URLs, preserving
|
||||
the existing checks for local files and encoded separators. Unsupported
|
||||
schemes, remote file hosts, and a bare `file://` remain excluded. These Node
|
||||
discovery rules do not apply to page targets or other runtime products.
|
||||
|
||||
Reconciliation retains an unresolved `target_location` object and reports
|
||||
`runtime-location-unresolved` with unknown confidence for that target. It
|
||||
does not derive a static match from the reported pathname; verified loaded
|
||||
script locations remain available for independent matching.
|
||||
|
||||
## Passive protocol boundary
|
||||
|
||||
The provider sends exactly two protocol commands:
|
||||
|
||||
@@ -537,9 +537,9 @@
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "1a32398486b1e65f50a1a81de2d8867e6a4c296c165bf9994e53034b466c41b4",
|
||||
"tools_sha256": "3ff61716cd0a493b077570af265db57a5dd7dde8489cea91077b9402ce0e6de5",
|
||||
"prompts_sha256": "fa889fc671185ac9c968c6dc8309cf609e709cdc837dbbf39b7cdfadb84ac2d7",
|
||||
"combined_sha256": "a87e48bc626cb4361f494c074e71852304bfedee17fbba89976d32bb6244f230",
|
||||
"combined_sha256": "b446bd2412595907a480eb42b2d7e8a6ac32df9685dc79b844c3be9eb563a1c9",
|
||||
"providers_sha256": "c5a88a49990fafc8244972ee8ff543fc19995eb79db960da55f6a311e15340d1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,13 @@ fixture. It checks the recovered case values against the source cases and
|
||||
requires unresolved table bounds or case mappings to remain visible as
|
||||
residual unknowns.
|
||||
|
||||
`npm run verify:inspector` requires the supported Node.js runtime and installed
|
||||
REA dependencies. CI runs it on Linux and Windows. It starts owned loopback
|
||||
Node Inspector fixtures and verifies discovery and passive observation through
|
||||
the CLI and stdio MCP, including special filenames, unresolved discovery
|
||||
locations, and independently resolved loaded scripts. Double-quote filenames
|
||||
are tested on POSIX only because Windows does not support them.
|
||||
|
||||
## Apple Interface Builder archives
|
||||
|
||||
`npm run verify:interface-builder` compiles the source-owned AppKit XIB into a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_fcfc6a2f890ccb4a5a7d19dce941b8f7644f05babd89e223100ef7b8b7a55afc",
|
||||
"manifest_id": "ecm_81d6fa3644c733735382fec83f2fd5b9dbfbde9d1d914f1321597ca54b388c5c",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "6a884e8e2df17da0df0e8fda01aa3d66726fd7454c5606752c2b3ba3aa9e5449"
|
||||
"sha256": "ea76bbc02f0f34b7659cd2d41539f4095957ebd2d458c1a885a9dd7600396914"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -1,82 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const root = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../tests/conformance/runtime-inspector",
|
||||
);
|
||||
const targetPath = resolve(root, "node-target.mjs");
|
||||
const child = spawn(process.execPath, ["--inspect=127.0.0.1:0", targetPath], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "rea-inspector-")));
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_V8_INSPECTOR_OBSERVE_ENABLED: "true",
|
||||
};
|
||||
const client = new Client({
|
||||
name: "rea-inspector-conformance",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: ["scripts/rea.mjs", "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const names = [
|
||||
"node-target.mjs",
|
||||
"plain.cjs",
|
||||
"with space.cjs",
|
||||
"with_under_score.cjs",
|
||||
"with#hash.cjs",
|
||||
"with%percent.cjs",
|
||||
"with%23hash.cjs",
|
||||
...(process.platform === "win32" ? [] : ['with"quote.cjs']),
|
||||
];
|
||||
|
||||
try {
|
||||
const webSocketUrl = await inspectorUrl(child);
|
||||
const socket = new URL(webSocketUrl);
|
||||
await client.connect(transport);
|
||||
for (const name of names) {
|
||||
const targetPath =
|
||||
name === "node-target.mjs"
|
||||
? await realpath(
|
||||
join(repository, "tests/conformance/runtime-inspector", name),
|
||||
)
|
||||
: join(root, name);
|
||||
if (name !== "node-target.mjs")
|
||||
await writeFile(targetPath, "setInterval(() => {}, 1000);\n");
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["--inspect=127.0.0.1:0", targetPath],
|
||||
{
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
);
|
||||
try {
|
||||
await verifyTarget(child, targetPath, name);
|
||||
} finally {
|
||||
await terminate(child);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.close();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTarget(child, targetPath, name) {
|
||||
const socket = new URL(await inspectorUrl(child));
|
||||
const endpoint = `http://127.0.0.1:${socket.port}`;
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_V8_INSPECTOR_OBSERVE_ENABLED: "true",
|
||||
};
|
||||
const listed = await runCli(
|
||||
["list-javascript-runtime-targets", endpoint, "--json"],
|
||||
environment,
|
||||
);
|
||||
const target = listed.normalized_result?.targets?.[0];
|
||||
if (
|
||||
listed.operation !== "list_javascript_runtime_targets" ||
|
||||
typeof target?.target_id !== "string" ||
|
||||
target.location?.kind !== "file" ||
|
||||
target.location.file_path !== targetPath
|
||||
)
|
||||
throw new Error("Real Node Inspector target discovery did not match");
|
||||
const observed = await runCli(
|
||||
[
|
||||
"observe-javascript-runtime",
|
||||
endpoint,
|
||||
target.target_id,
|
||||
"--runtime-kind",
|
||||
"node",
|
||||
"--observation-ms",
|
||||
"100",
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
);
|
||||
const result = observed.normalized_result;
|
||||
if (
|
||||
observed.operation !== "observe_javascript_runtime" ||
|
||||
observed.provider?.id !== "rea-v8-inspector" ||
|
||||
result?.target?.target_id !== target.target_id ||
|
||||
result?.target?.runtime_kind !== "node" ||
|
||||
!Array.isArray(result?.scripts?.items) ||
|
||||
result.scripts.items.length === 0
|
||||
)
|
||||
throw new Error("Real Node Inspector observation did not match");
|
||||
if (
|
||||
!Array.isArray(result.directly_observed) ||
|
||||
!Array.isArray(result.unavailable_without_instrumentation) ||
|
||||
result.capture?.truncated !== false
|
||||
)
|
||||
throw new Error("Real Node Inspector capture was incomplete");
|
||||
const response = await fetch(`${endpoint}/json/list`);
|
||||
assert.equal(response.ok, true);
|
||||
const discovery = await response.json();
|
||||
assert.equal(discovery.length, 1);
|
||||
const raw = discovery[0];
|
||||
assert.equal(typeof raw.id, "string");
|
||||
// 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);
|
||||
const expectedLocation = unresolved
|
||||
? {
|
||||
kind: "unresolved",
|
||||
reported_url: raw.url,
|
||||
reason: "unverifiable-file-location",
|
||||
}
|
||||
: { kind: "file", file_path: targetPath };
|
||||
const listed = await runCli([
|
||||
"list-javascript-runtime-targets",
|
||||
endpoint,
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(listed.operation, "list_javascript_runtime_targets");
|
||||
const inventory = listed.normalized_result;
|
||||
assert.equal(inventory.targets.length, 1);
|
||||
assert.equal(inventory.targets[0].target_id, raw.id);
|
||||
assert.deepEqual(inventory.targets[0].location, expectedLocation);
|
||||
assert.equal(inventory.excluded.unsupported_location, 0);
|
||||
const mcpList = await runMcp("list_javascript_runtime_targets", {
|
||||
inspector_endpoint: endpoint,
|
||||
});
|
||||
assert.deepEqual(mcpList, inventory);
|
||||
|
||||
const observed = await runCli([
|
||||
"observe-javascript-runtime",
|
||||
endpoint,
|
||||
raw.id,
|
||||
"--runtime-kind",
|
||||
"node",
|
||||
"--observation-ms",
|
||||
"100",
|
||||
"--json",
|
||||
]);
|
||||
assert.equal(observed.operation, "observe_javascript_runtime");
|
||||
assert.equal(observed.provider.id, "rea-v8-inspector");
|
||||
const mcpObservation = await runMcp("observe_javascript_runtime", {
|
||||
inspector_endpoint: endpoint,
|
||||
target_id: raw.id,
|
||||
runtime_kind: "node",
|
||||
observation_ms: 100,
|
||||
});
|
||||
for (const result of [observed.normalized_result, mcpObservation])
|
||||
verifyObservation(result, raw.id, expectedLocation, targetPath);
|
||||
assert.deepEqual(mcpObservation.target, observed.normalized_result.target);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: "pass",
|
||||
provider: observed.provider.id,
|
||||
target_id: target.target_id,
|
||||
platform: process.platform,
|
||||
node: process.version,
|
||||
fixture: name,
|
||||
target_id: raw.id,
|
||||
discovery_url: raw.url,
|
||||
target_location: expectedLocation,
|
||||
cli_scripts: observed.normalized_result.scripts.items.length,
|
||||
mcp_scripts: mcpObservation.scripts.items.length,
|
||||
listed_evidence_id: listed.evidence_id,
|
||||
observation_evidence_id: observed.evidence_id,
|
||||
scripts: result.scripts.items.length,
|
||||
contexts: result.execution_contexts.length,
|
||||
endpoint,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
await terminate(child);
|
||||
}
|
||||
|
||||
function verifyObservation(result, targetId, expectedLocation, targetPath) {
|
||||
assert.equal(result.target.target_id, targetId);
|
||||
assert.equal(result.target.runtime_kind, "node");
|
||||
assert.deepEqual(result.target.location, expectedLocation);
|
||||
assert.equal(result.capture.truncated, false);
|
||||
assert.ok(Array.isArray(result.directly_observed));
|
||||
assert.ok(Array.isArray(result.unavailable_without_instrumentation));
|
||||
assert.ok(
|
||||
result.scripts.items.some(
|
||||
({ location }) =>
|
||||
location.kind === "file" && location.file_path === targetPath,
|
||||
),
|
||||
`Actual entry script was not independently resolved: ${targetPath}`,
|
||||
);
|
||||
if (expectedLocation.kind === "unresolved")
|
||||
assert.ok(
|
||||
result.unknowns.includes(
|
||||
"The discovery-reported file location cannot be verified; loaded script locations are resolved independently from Debugger.scriptParsed.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function runMcp(name, arguments_) {
|
||||
const response = await client.callTool({ name, arguments: arguments_ });
|
||||
assert.notEqual(
|
||||
response.isError,
|
||||
true,
|
||||
JSON.stringify(response.structuredContent),
|
||||
);
|
||||
assert.ok(response.structuredContent?.result);
|
||||
return response.structuredContent.result;
|
||||
}
|
||||
|
||||
async function inspectorUrl(process_) {
|
||||
@@ -91,30 +189,41 @@ async function inspectorUrl(process_) {
|
||||
throw new Error("Node exited before reporting its Inspector URL");
|
||||
}
|
||||
|
||||
async function runCli(arguments_, environment) {
|
||||
async function runCli(arguments_) {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", ...arguments_],
|
||||
{
|
||||
cwd: resolve(dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
maxBuffer: 16 * 1_024 * 1_024,
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
async function terminate(process_) {
|
||||
if (process_.exitCode !== null) return;
|
||||
if (process_.exitCode !== null || process_.signalCode !== null) return;
|
||||
const exited = once(process_, "exit");
|
||||
process_.kill("SIGTERM");
|
||||
const terminated = await Promise.race([
|
||||
exited.then(() => true),
|
||||
new Promise((resolveTimeout) =>
|
||||
setTimeout(() => resolveTimeout(false), 2_000),
|
||||
),
|
||||
]);
|
||||
if (terminated || process_.exitCode !== null) return;
|
||||
process_.kill("SIGKILL");
|
||||
await exited;
|
||||
let timer;
|
||||
try {
|
||||
const terminated = await Promise.race([
|
||||
exited.then(() => true),
|
||||
new Promise((resolveTimeout) => {
|
||||
timer = setTimeout(() => resolveTimeout(false), 2_000);
|
||||
}),
|
||||
]);
|
||||
if (
|
||||
terminated ||
|
||||
process_.exitCode !== null ||
|
||||
process_.signalCode !== null
|
||||
)
|
||||
return;
|
||||
process_.kill("SIGKILL");
|
||||
await exited;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
|
||||
metadata:
|
||||
version: "23"
|
||||
tool_count: 122
|
||||
catalog_digest: "a87e48bc626cb4361f494c074e71852304bfedee17fbba89976d32bb6244f230"
|
||||
catalog_digest: "b446bd2412595907a480eb42b2d7e8a6ac32df9685dc79b844c3be9eb563a1c9"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
browserOriginSchema,
|
||||
sanitizeBrowserUrl,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { JavaScriptRuntimeLocation } from "../domain/javascriptRuntimeObservation.js";
|
||||
import type {
|
||||
JavaScriptRuntimeLocation,
|
||||
JavaScriptRuntimeTargetLocation,
|
||||
} from "../domain/javascriptRuntimeObservation.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
|
||||
export type RuntimeLocationDecision =
|
||||
@@ -48,7 +53,43 @@ export const authorizeRuntimeLocation = async (
|
||||
};
|
||||
};
|
||||
|
||||
/** Authorize a target whose Node Inspector URL omits its main entry path. */
|
||||
/** Interpret Node's lossy discovery metadata separately from scriptParsed URLs. */
|
||||
export const authorizeRuntimeTargetLocation = async (
|
||||
value: string,
|
||||
): Promise<RuntimeLocationDecision> => authorizeRuntimeLocation(value);
|
||||
context: { readonly type: string; readonly product: string },
|
||||
): Promise<
|
||||
| {
|
||||
readonly allowed: true;
|
||||
readonly location: JavaScriptRuntimeTargetLocation;
|
||||
}
|
||||
| { readonly allowed: false; readonly reason: "unsupported_location" }
|
||||
> => {
|
||||
if (
|
||||
context.type !== "node" ||
|
||||
!/^node\.js\/v/iu.test(context.product) ||
|
||||
!value.startsWith("file://")
|
||||
)
|
||||
return authorizeRuntimeLocation(value);
|
||||
const reportedPath = value.slice("file://".length);
|
||||
if (!reportedPath.startsWith("/") && !/^[a-z]:[/\\_]/iu.test(reportedPath))
|
||||
return authorizeRuntimeLocation(value);
|
||||
|
||||
// Node concatenates the pathname, then replaces quotes and backslashes with
|
||||
// underscores. A remaining underscore cannot prove which pathname was used.
|
||||
if (process.platform !== "win32" && !/[_"\\]/u.test(reportedPath)) {
|
||||
if (reportedPath.startsWith("/")) {
|
||||
const decision = await authorizeRuntimeLocation(
|
||||
pathToFileURL(reportedPath).href,
|
||||
);
|
||||
if (decision.allowed) return decision;
|
||||
}
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
location: {
|
||||
kind: "unresolved",
|
||||
reported_url: value,
|
||||
reason: "unverifiable-file-location",
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -24,7 +24,8 @@ export const createInspectorExclusionCounts = (): Record<
|
||||
export const describeInspectorTargetLimitations = (): 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.",
|
||||
"Target IDs and locations are authorized, but the Inspector protocol does not authenticate an operating-system process ID or Electron role.",
|
||||
"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.",
|
||||
"No runtime graph depth is traversed because passive Inspector events do not establish require/import caller edges.",
|
||||
];
|
||||
@@ -120,6 +121,11 @@ export const finalizeInspectorCapture = async ({
|
||||
"script unload events",
|
||||
],
|
||||
unknowns: [
|
||||
...(target.location.kind === "unresolved"
|
||||
? [
|
||||
"The discovery-reported file location cannot be verified; loaded script locations are resolved independently from Debugger.scriptParsed.",
|
||||
]
|
||||
: []),
|
||||
"Scripts collected before attachment may have been garbage-collected and therefore omitted.",
|
||||
"A bounded observation window cannot establish that an unobserved script or behavior never occurs.",
|
||||
"The declared Node/Electron process role is not authenticated by the Inspector protocol.",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
import type {
|
||||
JavaScriptRuntimeTargetList,
|
||||
JavaScriptRuntimeLocation,
|
||||
JavaScriptRuntimeTargetLocation,
|
||||
} from "../domain/javascriptRuntimeObservation.js";
|
||||
import {
|
||||
BrowserObservationError,
|
||||
@@ -111,5 +111,5 @@ const validatedInspectorWebSocket = (
|
||||
|
||||
/** Authorized target plus its durable location. */
|
||||
export interface AuthorizedV8InspectorTarget extends V8InspectorTarget {
|
||||
readonly location: JavaScriptRuntimeLocation;
|
||||
readonly location: JavaScriptRuntimeTargetLocation;
|
||||
}
|
||||
@@ -90,7 +90,10 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
|
||||
const allowed: AuthorizedV8InspectorTarget[] = [];
|
||||
const excluded = createInspectorExclusionCounts();
|
||||
for (const target of discovery.targets) {
|
||||
const decision = await authorizeRuntimeTargetLocation(target.url);
|
||||
const decision = await authorizeRuntimeTargetLocation(target.url, {
|
||||
type: target.type,
|
||||
product: discovery.runtime.product,
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
excluded[decision.reason] += 1;
|
||||
continue;
|
||||
@@ -128,7 +131,11 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
|
||||
"observe_javascript_runtime",
|
||||
options.signal,
|
||||
);
|
||||
const target = await authorizedTarget(discovery.targets, input);
|
||||
const target = await authorizedTarget(
|
||||
discovery.targets,
|
||||
input,
|
||||
discovery.runtime.product,
|
||||
);
|
||||
if (input.runtime_kind !== undefined)
|
||||
assertRuntimeKind(target, input.runtime_kind);
|
||||
connection = await CdpConnection.connect(
|
||||
@@ -172,6 +179,7 @@ const projectTarget = (target: AuthorizedV8InspectorTarget) => ({
|
||||
const authorizedTarget = async (
|
||||
targets: readonly V8InspectorTarget[],
|
||||
input: ObserveJavaScriptRuntimeInput,
|
||||
product: string,
|
||||
): Promise<AuthorizedV8InspectorTarget> => {
|
||||
const target = targets.find(({ id }) => id === input.target_id);
|
||||
if (target === undefined)
|
||||
@@ -179,7 +187,10 @@ const authorizedTarget = async (
|
||||
"observe_javascript_runtime",
|
||||
"target_not_found",
|
||||
);
|
||||
const decision = await authorizeRuntimeTargetLocation(target.url);
|
||||
const decision = await authorizeRuntimeTargetLocation(target.url, {
|
||||
type: target.type,
|
||||
product,
|
||||
});
|
||||
if (
|
||||
target.attached ||
|
||||
!decision.allowed ||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS = [
|
||||
name: "list_javascript_runtime_targets",
|
||||
...toolContractMetadata("list_javascript_runtime_targets"),
|
||||
description:
|
||||
"List every attachable Node/Electron V8 Inspector target from an explicit literal-loopback endpoint. Every returned target and script location exposed by that Inspector endpoint is eligible for Evidence.",
|
||||
"List every eligible Node/Electron V8 Inspector target from an explicit literal-loopback endpoint. Node discovery file locations that cannot be verified are preserved as unresolved reported URLs; they do not prove filesystem identity. Listing does not attach to targets.",
|
||||
kind: "runtime-provider",
|
||||
inputSchema: listJavaScriptRuntimeTargetsInputSchema,
|
||||
outputSchema: evidenceResultOf(javascriptRuntimeTargetListSchema),
|
||||
@@ -33,7 +33,7 @@ export const JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS = [
|
||||
name: "observe_javascript_runtime",
|
||||
...toolContractMetadata("observe_javascript_runtime"),
|
||||
description:
|
||||
"Attach passively to one Node/Electron V8 Inspector target by supplying its loopback Inspector endpoint and target ID. The provider rechecks that the target currently exists at that endpoint and is an attachable file or URL target. Captures Debugger.scriptParsed and Runtime execution-context events. REA never evaluates, pauses, resumes, reads source, or instruments the target; require/import edges, EventEmitter activity, and Electron IPC remain explicit unknowns. Reconcile the result with static Application Graph Evidence using reconcile_javascript_runtime. The selected endpoint exposes every target it serves to this tool.",
|
||||
"Attach passively to one Node/Electron V8 Inspector target by supplying its loopback Inspector endpoint and target ID. The provider rechecks target presence and attachability; a Node discovery file location may remain unresolved. Loaded script locations are validated independently from Debugger.scriptParsed. Captures script and Runtime execution-context metadata. REA never evaluates, pauses, resumes, reads source, or instruments the target; require/import edges, EventEmitter activity, and Electron IPC remain explicit unknowns. Reconcile the result with static Application Graph Evidence using reconcile_javascript_runtime. The selected endpoint exposes every target it serves to this tool.",
|
||||
kind: "runtime-provider",
|
||||
inputSchema: observeJavaScriptRuntimeInputSchema,
|
||||
outputSchema: evidenceResultOf(javascriptRuntimeObservationSchema),
|
||||
|
||||
@@ -64,11 +64,27 @@ export type JavaScriptRuntimeLocation = z.infer<
|
||||
typeof javascriptRuntimeLocationSchema
|
||||
>;
|
||||
|
||||
/** Discovery metadata can identify an endpoint target without verifying its file location. */
|
||||
export const javascriptRuntimeUnresolvedLocationSchema = z.strictObject({
|
||||
kind: z.literal("unresolved"),
|
||||
reported_url: z.string().min(1),
|
||||
reason: z.literal("unverifiable-file-location"),
|
||||
});
|
||||
|
||||
/** Verified locations and explicitly unresolved discovery metadata. */
|
||||
export const javascriptRuntimeTargetLocationSchema = z.union([
|
||||
javascriptRuntimeLocationSchema,
|
||||
javascriptRuntimeUnresolvedLocationSchema,
|
||||
]);
|
||||
export type JavaScriptRuntimeTargetLocation = z.infer<
|
||||
typeof javascriptRuntimeTargetLocationSchema
|
||||
>;
|
||||
|
||||
const javascriptRuntimeTargetSchema = z.strictObject({
|
||||
target_id: z.string().min(1),
|
||||
protocol_type: z.string().min(1),
|
||||
attached: z.boolean(),
|
||||
location: javascriptRuntimeLocationSchema,
|
||||
location: javascriptRuntimeTargetLocationSchema,
|
||||
});
|
||||
|
||||
/** Complete V8 Inspector target inventory exposed by the selected endpoint. */
|
||||
|
||||
@@ -66,6 +66,14 @@ const reconcileEntity = (
|
||||
index: CandidateIndex,
|
||||
layers: readonly ParsedStaticLayer[],
|
||||
): EvaluatedMatch => {
|
||||
if (entity.location.kind === "unresolved")
|
||||
return unresolved(entity, {
|
||||
status: "unknown",
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
reason: "runtime-location-unresolved",
|
||||
candidates: [],
|
||||
});
|
||||
const locations = candidatesWithLocation(entity, index, layers);
|
||||
if (entity.sourceSha256 !== null) {
|
||||
const content =
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
javascriptRuntimeObservationSchema,
|
||||
javascriptRuntimeKindSchema,
|
||||
type JavaScriptRuntimeObservation,
|
||||
type JavaScriptRuntimeLocation,
|
||||
} from "./javascriptRuntimeObservation.js";
|
||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||
import {
|
||||
@@ -67,6 +68,16 @@ interface NormalizedV8Inspection {
|
||||
readonly title: string;
|
||||
readonly attached: boolean;
|
||||
readonly url: string;
|
||||
}
|
||||
| {
|
||||
readonly target_id: string;
|
||||
readonly type: string;
|
||||
readonly title: string;
|
||||
readonly attached: boolean;
|
||||
readonly unresolved_location: Extract<
|
||||
JavaScriptRuntimeObservation["target"]["location"],
|
||||
{ kind: "unresolved" }
|
||||
>;
|
||||
};
|
||||
readonly frames: readonly [];
|
||||
readonly scripts: {
|
||||
@@ -305,7 +316,9 @@ const normalizeV8Inspection = (
|
||||
type: result.target.protocol_type,
|
||||
title: result.target.runtime_kind,
|
||||
attached: result.target.attached,
|
||||
...runtimeLocation(result.target.location),
|
||||
...(result.target.location.kind === "unresolved"
|
||||
? { unresolved_location: result.target.location }
|
||||
: runtimeLocation(result.target.location)),
|
||||
},
|
||||
frames: [],
|
||||
scripts: {
|
||||
@@ -359,7 +372,7 @@ const normalizeV8Inspection = (
|
||||
});
|
||||
|
||||
const runtimeLocation = (
|
||||
location: JavaScriptRuntimeObservation["target"]["location"],
|
||||
location: JavaScriptRuntimeLocation,
|
||||
): { readonly file_path: string } | { readonly url: string } => {
|
||||
if (location.kind === "file") return { file_path: location.file_path };
|
||||
if (location.kind === "url") return { url: location.sanitized_url };
|
||||
|
||||
@@ -87,9 +87,11 @@ const captureSummaries = (
|
||||
target_node_id: targetNode.node_id,
|
||||
target_key: capture.inspection.target.target_id,
|
||||
target_location:
|
||||
"url" in capture.inspection.target
|
||||
? capture.inspection.target.url
|
||||
: capture.inspection.target.file_path,
|
||||
"unresolved_location" in capture.inspection.target
|
||||
? capture.inspection.target.unresolved_location
|
||||
: "url" in capture.inspection.target
|
||||
? capture.inspection.target.url
|
||||
: capture.inspection.target.file_path,
|
||||
frames: capture.inspection.frames.length,
|
||||
scripts: capture.inspection.scripts.items.length,
|
||||
workers: capture.inspection.workers.length,
|
||||
|
||||
@@ -14,7 +14,10 @@ export interface RuntimeReconciliationEntity {
|
||||
readonly node: ApplicationNode;
|
||||
readonly capture: ParsedRuntimeCapture;
|
||||
readonly runtimeKey: string;
|
||||
readonly location: { readonly kind: "file" | "url"; readonly value: string };
|
||||
readonly location: {
|
||||
readonly kind: "file" | "url" | "unresolved";
|
||||
readonly value: string;
|
||||
};
|
||||
readonly frameKey: string | null;
|
||||
readonly sourceSha256: string | null;
|
||||
readonly staticKind: "renderer" | "javascript" | "worker" | "service-worker";
|
||||
@@ -73,9 +76,14 @@ const targetEntity = (
|
||||
): RuntimeReconciliationEntity => {
|
||||
const target = capture.inspection.target;
|
||||
const location =
|
||||
"url" in target
|
||||
? ({ kind: "url", value: target.url } as const)
|
||||
: ({ kind: "file", value: target.file_path } as const);
|
||||
"unresolved_location" in target
|
||||
? ({
|
||||
kind: "unresolved",
|
||||
value: target.unresolved_location.reported_url,
|
||||
} as const)
|
||||
: "url" in target
|
||||
? ({ kind: "url", value: target.url } as const)
|
||||
: ({ kind: "file", value: target.file_path } as const);
|
||||
return runtimeEntity(capture, {
|
||||
kind: "target",
|
||||
nodeKind: "target",
|
||||
@@ -89,7 +97,13 @@ const targetEntity = (
|
||||
label: target.title,
|
||||
properties: {
|
||||
runtime_type: target.type,
|
||||
location: location.value,
|
||||
...(location.kind === "unresolved"
|
||||
? {
|
||||
reported_url: location.value,
|
||||
location_kind: "unresolved",
|
||||
location_reason: "unverifiable-file-location",
|
||||
}
|
||||
: { location: location.value }),
|
||||
attached: target.attached,
|
||||
},
|
||||
section: "target",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isAbsolute, resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
import { javascriptRuntimeUnresolvedLocationSchema } from "./javascriptRuntimeObservation.js";
|
||||
import {
|
||||
compareCodePoints,
|
||||
javascriptApplicationGraphSchema,
|
||||
@@ -152,7 +153,10 @@ const captureSummarySchema = z.strictObject({
|
||||
kind: z.enum(["browser", "electron", "v8-inspector", "electron-active"]),
|
||||
target_node_id: nodeIdSchema,
|
||||
target_key: boundedTextSchema,
|
||||
target_location: z.string().min(1),
|
||||
target_location: z
|
||||
.string()
|
||||
.min(1)
|
||||
.or(javascriptRuntimeUnresolvedLocationSchema),
|
||||
frames: z.number().int().min(0),
|
||||
scripts: z.number().int().min(0),
|
||||
workers: z.number().int().min(0),
|
||||
@@ -167,6 +171,7 @@ const reconciliationReasonSchema = z.enum([
|
||||
"captured-content-disagrees-with-static-location",
|
||||
"no-static-content-match",
|
||||
"no-authorized-location-mapping",
|
||||
"runtime-location-unresolved",
|
||||
"runtime-frame-unattributed",
|
||||
"static-or-runtime-coverage-incomplete",
|
||||
"reconciliation-limit-reached",
|
||||
|
||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON =
|
||||
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inventory_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"extract_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"decode_interface_builder","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_keyed_archive","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_asset_catalog","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"observe_native_ui","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":true,"mayShowUi":true,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"capture_native_ui_scenario","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"Line truncated
|
||||
'{"auxiliaryProviders":[{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inventory_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"extract_artifact","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"decode_interface_builder","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":false,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_keyed_archive","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["DMG child inventory is macOS-only and requires per-call approval plus operator policy; PKG remains root-hash-only.","ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."],"operation":"inspect_asset_catalog","provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"reason":null}],"identity":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"}},{"capabilities":[{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"observe_native_ui","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":true,"mayShowUi":true,"mayWriteFilesystem":true,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"operation":"capture_native_ui_scenario","provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"reason":null},{"available":true,"effects":{"changesPermissions":false,"launchesProcess":true,"mayAccessNetwork":false,"mayShowUi":false,"mayWriteFilesystem":false,"mutatesArtifact":false,"requiresRoot":false},"limitations":["Availability and textual formats depend on the installed macOS/Xcode toolchain."],"Line truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function entryFile(name: string): Promise<string> {
|
||||
const root = await createTestTempDirectory("rea-node-discovery-");
|
||||
roots.push(root);
|
||||
const entry = join(root, name);
|
||||
await writeFile(entry, "setInterval(() => {}, 1000);\n");
|
||||
return entry;
|
||||
}
|
||||
|
||||
test("lists and observes a Windows target without inventing a decoded path", async () => {
|
||||
const entry = await entryFile("entry.js");
|
||||
const reported = "file://C:_tools_entry.js";
|
||||
const fake = await startFakeV8Inspector({
|
||||
targetUrl: reported,
|
||||
scriptUrls: [pathToFileURL(entry).href],
|
||||
});
|
||||
try {
|
||||
const provider = new V8InspectorProvider();
|
||||
const listed = await provider.listTargets({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
});
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) return;
|
||||
expect(listed.value.targets).toMatchObject([
|
||||
{
|
||||
target_id: fake.targetId,
|
||||
location: {
|
||||
kind: "unresolved",
|
||||
reported_url: reported,
|
||||
reason: "unverifiable-file-location",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(listed.value.excluded.unsupported_location).toBe(0);
|
||||
expect(fake.commands).toEqual([]);
|
||||
|
||||
const missing = await provider.observe({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
target_id: "not-the-selected-target",
|
||||
observation_ms: 10,
|
||||
});
|
||||
expect(missing).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "target_not_found" },
|
||||
});
|
||||
expect(fake.commands).toEqual([]);
|
||||
|
||||
const observed = await provider.observe({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
target_id: fake.targetId,
|
||||
observation_ms: 10,
|
||||
});
|
||||
expect(observed.ok).toBe(true);
|
||||
if (!observed.ok) return;
|
||||
expect(observed.value.target.location).toEqual(
|
||||
listed.value.targets[0]?.location,
|
||||
);
|
||||
expect(observed.value.scripts.items).toMatchObject([
|
||||
{ location: { kind: "file", file_path: entry } },
|
||||
]);
|
||||
expect(observed.value.unknowns).toContain(
|
||||
"The discovery-reported file location cannot be verified; loaded script locations are resolved independently from Debugger.scriptParsed.",
|
||||
);
|
||||
expect(fake.commands.map(({ method }) => method)).toEqual([
|
||||
"Runtime.enable",
|
||||
"Debugger.enable",
|
||||
]);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test
|
||||
.runIf(process.platform !== "win32")
|
||||
.each(["entry#hash.js", "entry%percent.js", "entry%23hash.js"])(
|
||||
"interprets %s as a literal Node discovery path on POSIX",
|
||||
async (name) => {
|
||||
const entry = await entryFile(name);
|
||||
const fake = await startFakeV8Inspector({ targetUrl: `file://${entry}` });
|
||||
try {
|
||||
const listed = await new V8InspectorProvider().listTargets({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
});
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) return;
|
||||
expect(listed.value.targets).toMatchObject([
|
||||
{ location: { kind: "file", file_path: entry } },
|
||||
]);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps a lossy discovery name unresolved even when its underscore alias exists", async () => {
|
||||
const alias = await entryFile("entry_quote.js");
|
||||
const reported = `file://${alias}`;
|
||||
const fake = await startFakeV8Inspector({ targetUrl: reported });
|
||||
try {
|
||||
const listed = await new V8InspectorProvider().listTargets({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
});
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) return;
|
||||
expect(listed.value.targets).toMatchObject([
|
||||
{ location: { kind: "unresolved", reported_url: reported } },
|
||||
]);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
"file://",
|
||||
"file://remote.test/share/entry.js",
|
||||
"javascript:entry()",
|
||||
"file://a:secret@remote.test/entry.js",
|
||||
])("keeps unsupported discovery location %s excluded", async (targetUrl) => {
|
||||
const fake = await startFakeV8Inspector({ targetUrl });
|
||||
try {
|
||||
const listed = await new V8InspectorProvider().listTargets({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
});
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) return;
|
||||
expect(listed.value.targets).toEqual([]);
|
||||
expect(listed.value.excluded.unsupported_location).toBe(1);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ targetType: "page" as const },
|
||||
{ runtimeProduct: "Chrome/132.0" },
|
||||
])("does not apply Node discovery semantics to %j", async (context) => {
|
||||
const fake = await startFakeV8Inspector({
|
||||
targetUrl: "file://C:_tools_entry.js",
|
||||
...context,
|
||||
});
|
||||
try {
|
||||
const listed = await new V8InspectorProvider().listTargets({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
});
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) return;
|
||||
expect(listed.value.targets).toEqual([]);
|
||||
expect(listed.value.excluded.unsupported_location).toBe(1);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not reinterpret lossy discovery paths in script metadata", async () => {
|
||||
const entry = await entryFile("entry.js");
|
||||
const fake = await startFakeV8Inspector({
|
||||
targetUrl: "file://C:_tools_entry.js",
|
||||
scriptUrls: [
|
||||
"file://C:_tools_entry.js",
|
||||
"file://remote.test/share/entry.js",
|
||||
pathToFileURL(entry).href,
|
||||
],
|
||||
});
|
||||
try {
|
||||
const observed = await new V8InspectorProvider().observe({
|
||||
inspector_endpoint: fake.endpoint,
|
||||
target_id: fake.targetId,
|
||||
observation_ms: 10,
|
||||
});
|
||||
expect(observed.ok).toBe(true);
|
||||
if (!observed.ok) return;
|
||||
expect(observed.value.scripts.items).toMatchObject([
|
||||
{ location: { kind: "file", file_path: entry } },
|
||||
]);
|
||||
expect(observed.value.scripts.excluded.unsupported_location).toBe(2);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
||||
import { createJavaScriptRuntimeObservationEvidence } from "../../../src/application/JavaScriptRuntimeObservationEvidence.js";
|
||||
import { V8InspectorProvider } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { reconcileJavaScriptRuntime } from "../../../src/domain/javascriptRuntimeReconciliation.js";
|
||||
import { startFakeV8Inspector } from "../../fixtures/fakeV8Inspector.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
test("keeps unresolved target identity unknown while matching verified loaded scripts", async () => {
|
||||
const root = await createTestTempDirectory("rea-unresolved-runtime-");
|
||||
const entry = join(root, "entry.js");
|
||||
const reportedUrl = "file://C:_tools_entry.js";
|
||||
const fake = await startFakeV8Inspector({
|
||||
targetUrl: reportedUrl,
|
||||
scriptUrls: [pathToFileURL(entry).href],
|
||||
});
|
||||
try {
|
||||
await writeFile(entry, "export const fixture = true;\n");
|
||||
const analysis = await analyzeJavaScriptApplication({ input_path: root });
|
||||
expect(analysis.ok).toBe(true);
|
||||
if (!analysis.ok) throw analysis.error;
|
||||
const provider = new V8InspectorProvider();
|
||||
const input = {
|
||||
inspector_endpoint: fake.endpoint,
|
||||
target_id: fake.targetId,
|
||||
observation_ms: 10,
|
||||
};
|
||||
const observed = await provider.observe(input);
|
||||
expect(observed.ok).toBe(true);
|
||||
if (!observed.ok) throw observed.error;
|
||||
const evidence = createJavaScriptRuntimeObservationEvidence(
|
||||
"observe_javascript_runtime",
|
||||
input,
|
||||
observed.value,
|
||||
provider.identity(),
|
||||
);
|
||||
const result = reconcileJavaScriptRuntime({
|
||||
static_layers: [{ role: "application", analysis: analysis.value }],
|
||||
runtime_observations: [evidence],
|
||||
});
|
||||
expect(result.reconciliations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
entity_kind: "target",
|
||||
status: "unknown",
|
||||
basis: "none",
|
||||
confidence: "unknown",
|
||||
reason: "runtime-location-unresolved",
|
||||
candidate_static_count: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
entity_kind: "script",
|
||||
status: "matched",
|
||||
basis: "artifact-path",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.runtime_captures[0]?.target_location).toEqual({
|
||||
kind: "unresolved",
|
||||
reported_url: reportedUrl,
|
||||
reason: "unverifiable-file-location",
|
||||
});
|
||||
const target = result.graph.nodes.find(({ kind }) => kind === "target");
|
||||
expect(target?.observations[0]?.properties).toMatchObject({
|
||||
reported_url: reportedUrl,
|
||||
location_kind: "unresolved",
|
||||
});
|
||||
expect(target?.observations[0]?.properties).not.toHaveProperty("location");
|
||||
expect(
|
||||
result.graph.edges.filter(
|
||||
({ relation, source_node_id }) =>
|
||||
relation === "observed_as" && source_node_id === target?.node_id,
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await fake.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Vendored
+2
-1
@@ -11,6 +11,7 @@ interface InspectorCommand {
|
||||
interface FakeV8InspectorOptions {
|
||||
readonly targetUrl: string;
|
||||
readonly targetType?: "node" | "page";
|
||||
readonly runtimeProduct?: string;
|
||||
readonly scriptUrls?: readonly string[];
|
||||
readonly scriptHashes?: readonly string[];
|
||||
readonly additionalTargetUrl?: string;
|
||||
@@ -39,7 +40,7 @@ export const startFakeV8Inspector = async (
|
||||
if (request.url === "/json/version") {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
Browser: "node.js/v24.4.1",
|
||||
Browser: options.runtimeProduct ?? "node.js/v24.4.1",
|
||||
"Protocol-Version": "1.3",
|
||||
"V8-Version": "13.6",
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user