fix(capture): retain Electron observations and HAR resource failures

This commit is contained in:
morluto 2026-10-09 06:48:13 +08:00
parent 6c13fc8e0a
commit 44fd18305f
27 changed files with 1462 additions and 145 deletions

View File

@ -86,25 +86,33 @@ rea capture-electron-scenario scenario.json --json
The result records action status and targets, correlated app/window/WebContents,
preload, session, navigation, shell, permission, popup, download, protocol,
native-addon, process, and IPC timeline events. Event retention has a 16 MiB
accounting budget for full channel strings, argument-shape arrays, and an
estimated object-graph cost. Admission computes an exact JSON byte length
incrementally, then charges twice that size plus 256 bytes per event and 8 bytes
per argument shape. The doubled JSON charge covers the timeline and IPC views;
the additional charge estimates their in-process object and array storage. This
is a resource estimate, not an exact V8 heap measurement. An event that does not
fit is dropped whole. The result reports the estimate, serialized-byte upper
bound, dropped event families, and
observed/dropped totals for IPC and runtime events. Retained sequence numbers
keep their original capture identities. Payload values are never retained.
native-addon, process, and IPC timeline events. IPC channels and argument-shape
metadata retain their observed lengths; payload values are never retained.
Renderer crash/restart and deep-link actions are synthetic
scenario controls; their attempted and observed outcomes remain in the
timeline. The active hook blocks and records external shell/navigation,
permission, download, popup, updater, and OS-integration effects. The timeline
is explicitly partial when attachment starts after application activity. The
owned experiment has an internal 60-second deadline and a 5-second per-action
timeout so hung work reaches process cleanup; these are lifecycle timeouts, not
caller-selected capture limits.
startup has a 60-second deadline. Actions wait until completion or request
cancellation; a click whose target never appears remains pending until cancelled.
CLI SIGINT/SIGTERM requests cancellation and awaits owned-process cleanup.
Window IDs, URLs, and cached titles come from one main-process Electron snapshot,
so a crashed renderer does not require a successful renderer-side title query.
Window instrumentation uses Electron's window-created event, including runtimes
with a read-only `BrowserWindow` export. Preload configuration comes from the
window's runtime metadata, including Electron's optional internal preload
descriptor when web preferences omit it. This descriptor is verified on
Electron 44.7.0; runtimes that expose neither representation retain an unavailable
path. A configured path or working IPC handler does not prove preload execution.
If cleanup cannot be verified, the error retains collected observations in
`details.partial_observation` and marks their application cleanup `unverified`.
Cleanup uncertainty does not authorize signaling unrelated processes.
The real verifier exercises compiled CLI and stdio MCP discovery, page/script
inspection, static/runtime reconciliation, active observation, and CLI SIGTERM
cleanup before checking the IPC, renderer crash/reload, utility/child-process,
deep-link, and blocked-effect fixture. A cleanup error keeps the full lane failed
even when the retained capture contains useful observations.
Active Electron Evidence can also be supplied to
[`reconcile_javascript_runtime`](javascript-runtime-reconciliation.md). That

View File

@ -126,6 +126,15 @@ process cleanup. Owned decoder diagnostics retain at most 1 MiB across stdout
and stderr; the collector drops overflow as it arrives, reports the limit
failure and verifies cleanup. Heap limits alone do not establish aggregate RSS limits.
Observed decoder memory exhaustion returns `resource_constraint`, with the
selected capture, applicable limits and retained process diagnostics. HAR's
old-generation heap is fixed; inherited `NODE_OPTIONS` cannot raise it.
Record selection happens after complete decoding and does not reduce that
workload. Use a smaller capture exported by its producer or another decoder
with sufficient capacity, retaining the original for provenance. Subsets do
not establish complete-capture coverage. Repeating the same workload or
running provider health diagnostics does not resolve the heap constraint.
Stable regular-file reads reject symlinks, replacement and concurrent changes.
Private snapshots, declarations and tool configuration are removed before the
result returns. Cleanup uncertainty retains the selected capture, resource and

View File

@ -195,9 +195,11 @@ const createElectronActiveBoundaryPatches = ({
};
};
const patchSessionManager = (sessionModule) => {
const patchSessionManager = (sessionModule, app) => {
if (sessionModule === null || sessionModule === undefined) return;
patchSession(sessionModule.defaultSession);
if (app === undefined || app.isReady())
patchSession(sessionModule.defaultSession);
else app.once("ready", () => patchSession(sessionModule.defaultSession));
for (const method of ["fromPartition", "fromPath"]) {
const original = sessionModule[method];
if (typeof original !== "function") continue;

View File

@ -543,31 +543,44 @@ const patchWebContents = (webContents, windowId) => {
boundaries.patchWindowOpenHandler(webContents, contentsId);
};
const patchBrowserWindow = (electron) => {
const original = electron.BrowserWindow;
if (typeof original !== "function") return;
electron.BrowserWindow = new Proxy(original, {
construct(target, args, newTarget) {
const window = Reflect.construct(target, args, newTarget);
const targetId = identity(window, "window");
recordRuntime("window-lifecycle", "created", "completed", {
target: targetId,
argument_shape_values: args,
});
const preload = args[0]?.webPreferences?.preload;
const patchWindows = (app) => {
if (app === null || app === undefined) return;
app.on("browser-window-created", (_event, window) => {
const targetId = identity(window, "window");
recordRuntime("window-lifecycle", "created", "completed", {
target: targetId,
capture_method: "event-emitter",
});
try {
// Electron 44 separates the preload descriptor from WebPreferences.
// Read the engine's descriptor, never infer a path from application code.
const preferences = window.webContents.getLastWebPreferences?.();
const preload =
typeof preferences?.preload === "string"
? preferences.preload
: window.webContents._getPreloadScript?.()?.filePath;
if (typeof preload === "string")
recordRuntime("preload", "configured", "completed", {
target: targetId,
artifact_path: preload,
});
patchEmitter(window, (event) =>
recordRuntime("window-lifecycle", event, "observed", {
} catch (cause) {
recordRuntime(
"error",
cause instanceof Error ? cause.message : String(cause),
"failed",
{
target: targetId,
}),
error: true,
},
);
patchWebContents(window.webContents, targetId);
return window;
},
}
patchEmitter(window, (event) =>
recordRuntime("window-lifecycle", event, "observed", {
target: targetId,
}),
);
patchWebContents(window.webContents, targetId);
});
};
@ -599,10 +612,10 @@ try {
patchIpcMain(electron.ipcMain);
boundaries.patchIpcRenderer(electron.ipcRenderer);
boundaries.patchContextBridge(electron.contextBridge);
patchBrowserWindow(electron);
patchWindows(electron.app);
boundaries.patchUtilityProcess(electron.utilityProcess);
boundaries.patchShell(electron.shell);
boundaries.patchSessionManager(electron.session);
boundaries.patchSessionManager(electron.session, electron.app);
boundaries.patchApplicationEffects(electron.app);
boundaries.patchNativeAddonLoading();
boundaries.patchChildProcess();
@ -620,8 +633,17 @@ try {
});
throw reason;
});
} catch {
} catch (cause) {
globalThis.__reaElectronActiveHookError = true;
recordRuntime(
"error",
cause instanceof Error ? cause.message : String(cause),
"failed",
{
target: "active-hook-installation",
error: true,
},
);
}
globalThis.__reaElectronActiveSnapshot = () => ({

View File

@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
import { PlaywrightElectronActiveProvider } from "../dist/browser/PlaywrightElectronActiveProvider.js";
import { electronActiveObservationInputSchema } from "../dist/domain/javascript/electronActiveObservation.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
import { verifyElectronToolContracts } from "./verify/electron/tool-contracts.mjs";
const executable = process.env.REA_ELECTRON_EXECUTABLE;
if (executable === undefined || executable.length === 0)
@ -14,6 +15,7 @@ if (executable === undefined || executable.length === 0)
);
const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
await verifyElectronToolContracts({ executable, repositoryRoot });
const applicationRoot =
process.env.REA_ELECTRON_APPLICATION_ROOT ??
join(repositoryRoot, "tests/conformance/readiness/electron");
@ -60,6 +62,7 @@ const output = {
timeline: result.value.timeline,
windows: result.value.windows,
verified:
result.value.coverage.status === "partial_attach" &&
result.value.actions.every(({ status }) => status === "completed") &&
result.value.actions.some(
({ kind, window_index }) =>
@ -104,8 +107,8 @@ const output = {
kind === "process-lifecycle" && event === "child.spawn",
),
};
process.stdout.write(`${JSON.stringify(output)}\n`);
if (!output.verified)
throw new Error(
"real Electron verifier did not observe the readiness IPC round trip",
);
process.stdout.write(`${JSON.stringify(output)}\n`);

View File

@ -0,0 +1,364 @@
import assert from "node:assert/strict";
import { execFile, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { access, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import Ajv2020 from "ajv/dist/2020.js";
import {
cleanupOwnedProcessGroup,
readProcessRunId,
} from "../../../dist/process/ProcessOwnership.js";
const execute = promisify(execFile);
/** Exercise public Electron tools and cancellation using a real owned application. */
export async function verifyElectronToolContracts({
executable,
repositoryRoot,
}) {
const root = await realpath(
await mkdtemp(join(tmpdir(), "rea-electron-e2e-")),
);
const main = join(root, "main.cjs");
const ready = join(root, "ready");
const entrypoint = join(repositoryRoot, "scripts", "rea.mjs");
const runId = randomUUID();
await writeFile(
main,
`const {app,BrowserWindow}=require("electron");
app.whenReady().then(()=>{const window=new BrowserWindow();
window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringify(ready)},"ready");});`,
);
await writeFile(
join(root, "renderer.html"),
'<!doctype html><button id="run">Run</button><script src="renderer.js"></script>',
);
await writeFile(
join(root, "renderer.js"),
'document.querySelector("#run").addEventListener("click",()=>console.log("fixture-click"));',
);
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: {
PATH: process.env.PATH,
REA_LOG_LEVEL: "silent",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured/hopper",
},
stderr: "pipe",
});
const client = new Client({ name: "electron-real-e2e", version: "1" });
let serverStderr = "";
transport.stderr?.on("data", (chunk) => {
serverStderr += chunk.toString();
});
let application;
let stderr = "";
try {
await readProcessRunId(process.pid);
application = spawn(
executable,
[
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=0",
`--user-data-dir=${join(root, "profile")}`,
main,
],
{
cwd: root,
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
detached: true,
stdio: ["ignore", "ignore", "pipe"],
},
);
application.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const endpoint = await waitForEndpoint(application, () => stderr);
await client.connect(transport);
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const validators = new Map(
(await client.listTools()).tools.map((tool) => {
ajv.compile(tool.inputSchema);
return [
tool.name,
tool.outputSchema ? ajv.compile(tool.outputSchema) : undefined,
];
}),
);
const call = async (name, arguments_) => {
console.log(`PROBE ${name}`);
const result = await client.callTool(
{ name, arguments: arguments_ },
{ timeout: 150_000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
const validate = validators.get(name);
assert.ok(
validate?.(result.structuredContent),
JSON.stringify(validate?.errors),
);
return result.structuredContent;
};
let targets;
for (let attempt = 0; attempt < 30; attempt++) {
targets = await call("list_electron_targets", { cdp_endpoint: endpoint });
if (targets.result.targets.length > 0) break;
await pause(100);
}
const target = targets.result.targets.find(
(item) => item.file_path === join(root, "renderer.html"),
);
assert.ok(target, JSON.stringify(targets));
const page = await call("inspect_electron_page", {
cdp_endpoint: endpoint,
target_id: target.target_id,
include_script_sources: true,
observation_ms: 50,
});
assert.ok(
page.result.scripts.items.some(
(script) =>
script.file_path === join(root, "renderer.js") &&
script.source.included,
),
);
const staticAnalysis = await call("analyze_javascript_application", {
input_path: root,
});
const reconciled = await call("reconcile_javascript_runtime", {
static_layers: [
{
analysis: staticAnalysis.evidence,
runtime_mappings: [{ kind: "file-root", root }],
},
],
runtime_observations: [page.evidence],
});
assert.ok(reconciled.result.runtime_captures.length > 0);
const cli = await execute(
process.execPath,
[
entrypoint,
"inspect-electron-page",
endpoint,
target.target_id,
"--include-script-sources",
"--json",
],
{
env: { ...process.env, REA_LOG_LEVEL: "silent" },
timeout: 60_000,
maxBuffer: 32 * 1024 * 1024,
},
);
assert.equal(
JSON.parse(cli.stdout).normalized_result.target.file_path,
target.file_path,
);
const input = {
executable_path: executable,
application_path: main,
application_root: root,
actions: [{ step_id: "settle", kind: "wait", duration_ms: 50 }],
};
const active = await client.callTool(
{ name: "capture_electron_scenario", arguments: input },
{ timeout: 150_000 },
);
const capture = active.isError
? active.structuredContent.error.details.partial_observation.capture
: active.structuredContent.result;
if (active.isError) {
assert.equal(
active.structuredContent.error.code,
"cleanup_incomplete",
JSON.stringify(active),
);
assert.equal(capture.application.cleanup, "unverified");
} else {
assert.ok(
validators.get("capture_electron_scenario")(active.structuredContent),
);
assert.equal(capture.application.cleanup, "terminated-owned-process");
}
assert.equal(capture.actions[0].status, "completed");
assert.equal(
capture.coverage.status,
"partial_attach",
JSON.stringify(capture),
);
assert.ok(
capture.timeline.events.some(
(event) =>
event.kind === "window-lifecycle" && event.event === "created",
),
);
await client.ping();
console.log(
`PASS live Electron CLI/MCP discovery, inspection, reconciliation, and active observations; active cleanup=${capture.application.cleanup}`,
);
if (process.platform !== "win32")
await verifyCancellation({ input, ready, entrypoint, root });
} catch (cause) {
throw new Error(
`Electron public-tool verification failed: ${cause.message}\nMCP stderr:\n${serverStderr}`,
{ cause },
);
} finally {
await client.close();
await transport.close();
if (application?.pid) {
const cleanup = await cleanupOwnedProcessGroup({
runId,
leaderPid: application.pid,
processGroupId: application.pid,
expectedParentPid: process.pid,
expectedCommand: executable,
});
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
}
await rm(root, { recursive: true, force: true });
}
}
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function waitForEndpoint(application, diagnostics) {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const match = /DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//u.exec(
diagnostics(),
);
if (match) return `http://127.0.0.1:${match[1]}`;
assert.equal(application.exitCode, null, diagnostics());
await pause(100);
}
throw new Error(
`Electron did not expose a loopback endpoint: ${diagnostics()}`,
);
}
async function verifyCancellation({ input, ready, entrypoint, root }) {
await rm(ready, { force: true });
const child = spawn(
process.execPath,
[
entrypoint,
"capture-electron-scenario",
JSON.stringify({
...input,
actions: [{ step_id: "wait", kind: "wait", duration_ms: 30_000 }],
}),
"--json",
],
{
cwd: root,
env: { ...process.env, REA_LOG_LEVEL: "silent" },
stdio: ["ignore", "pipe", "pipe"],
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const exited = new Promise((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => resolve({ code, signal }));
});
let ownership;
try {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const table = (
await execute("ps", ["-eo", "pid=,ppid=,pgid=,args="], {
timeout: 5_000,
maxBuffer: 2 * 1024 * 1024,
})
).stdout;
for (const row of table.split("\n")) {
const entry = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/u.exec(row);
if (
entry &&
Number(entry[2]) === child.pid &&
entry[4].includes(input.application_path)
) {
const runId = await readProcessRunId(Number(entry[1]));
assert.ok(runId, "Electron child lacked its ownership token");
ownership = {
runId,
leaderPid: Number(entry[1]),
processGroupId: Number(entry[3]),
};
}
}
if (ownership) {
try {
await access(ready);
break;
} catch {
/* wait for actual fixture readiness */
}
}
assert.equal(child.exitCode, null, `${stdout}\n${stderr}`);
await pause(100);
}
assert.ok(ownership, "CLI did not acquire an owned Electron application");
await access(ready);
// The fixture's readiness precedes Playwright's Inspector acquisition;
// interrupt the long action rather than its separate startup boundary.
await pause(1_000);
child.kill("SIGTERM");
let timer;
const result = await Promise.race([
exited,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Electron CLI cancellation timed out")),
15_000,
);
}),
]).finally(() => clearTimeout(timer));
assert.equal(result.code, 143, `${stdout}\n${stderr}`);
assert.equal(result.signal, null);
const error = JSON.parse(stdout);
assert.ok(["cancelled", "cleanup_incomplete"].includes(error.code), stdout);
assert.equal(error.category, "cancelled", stdout);
assert.doesNotMatch(error.message, /rea doctor/u);
assert.equal(
error.details?.partial_observation?.kind,
"electron-active-observation",
stdout,
);
assert.ok(
error.details.partial_observation.capture.actions.some(
(action) => action.status === "cancelled",
),
);
assert.throws(() => process.kill(-ownership.processGroupId, 0), {
code: "ESRCH",
});
console.log(
`PASS real Electron CLI SIGTERM, retained cancelled actions, and owned group termination; reported code=${error.code}`,
);
} finally {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGTERM");
await exited;
}
if (ownership) {
const cleanup = await cleanupOwnedProcessGroup(ownership);
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
}
}
}

View File

@ -103,6 +103,8 @@ const metricSchema = z.strictObject({
const windowMetadataSchema = z.strictObject({
window_id: z.string(),
web_contents_id: z.string(),
url: z.string(),
title: z.string(),
visible: z.boolean().nullable(),
destroyed: z.boolean(),
});
@ -114,11 +116,6 @@ export type ElectronHookSnapshot = z.infer<typeof hookSnapshotSchema>;
export type ElectronHookEvent = z.infer<typeof hookEventSchema>;
export type ElectronMetrics = z.infer<typeof metricSchema>[];
type ElectronWindowMetadata = z.infer<typeof windowMetadataSchema>;
type ElectronPageSnapshot = {
readonly index: number;
readonly url: string;
readonly title: string;
};
/** Run explicit actions against provider-owned Electron windows. */
export const runElectronActions = async (
@ -128,12 +125,12 @@ export const runElectronActions = async (
): Promise<ElectronActions> => {
const actions: ElectronActions = [];
for (const action of input.actions) {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const actionStartedAt = Date.now();
const windowIndex = actionWindowIndex(action);
let selectedWindow: Page | undefined;
try {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
selectedWindow =
windowIndex === null
? undefined
@ -322,25 +319,23 @@ export const readApplicationState = async (
readonly electronVersion: string;
readonly hookSnapshot: ElectronHookSnapshot;
}> => {
const pages = application.windows();
const pageSnapshotsPromise: Promise<ElectronPageSnapshot[]> = Promise.all(
pages.map(async (page: Page, index): Promise<ElectronPageSnapshot> => ({
index,
url: page.url(),
title: await page.title(),
})),
);
const windowMetadataPromise: Promise<ElectronWindowMetadata[]> = application
.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().map(
(window: {
readonly id: number;
readonly webContents: { readonly id: number };
readonly webContents: {
readonly id: number;
getURL(): string;
getTitle(): string;
};
isDestroyed(): boolean;
isVisible(): boolean;
}) => ({
window_id: `window:${String(window.id)}`,
web_contents_id: `webContents:${String(window.webContents.id)}`,
url: window.webContents.getURL(),
title: window.webContents.getTitle(),
visible: window.isDestroyed() ? null : window.isVisible(),
destroyed: window.isDestroyed(),
}),
@ -392,33 +387,15 @@ export const readApplicationState = async (
};
})
.then((value) => hookSnapshotSchema.parse(value));
const [
pageSnapshots,
windowMetadata,
processMetrics,
electronVersion,
hookSnapshot,
] = await Promise.all([
pageSnapshotsPromise,
windowMetadataPromise,
processMetricsPromise,
electronVersionPromise,
hookSnapshotPromise,
]);
const [windowMetadata, processMetrics, electronVersion, hookSnapshot] =
await Promise.all([
windowMetadataPromise,
processMetricsPromise,
electronVersionPromise,
hookSnapshotPromise,
]);
return {
windows: pageSnapshots.map((page) => {
const metadata = windowMetadata[page.index];
if (metadata === undefined)
throw new BrowserObservationError(OPERATION, "window_metadata_missing");
return {
window_id: metadata.window_id,
web_contents_id: metadata.web_contents_id,
url: page.url,
title: page.title,
visible: metadata.visible,
destroyed: metadata.destroyed,
};
}),
windows: windowMetadata,
metrics: processMetrics,
electronVersion,
hookSnapshot,

View File

@ -58,7 +58,10 @@ const eventFamily = (
kind: ElectronHookEvent["kind"],
processType: string | null,
): string => {
if (kind === "preload" && processType === "main")
if (
kind === "preload" &&
(processType === "main" || processType === "browser")
)
return "preload-configuration";
if (kind.startsWith("ipc-renderer"))
return processType === "renderer" ? "renderer-ipc" : "ipc";
@ -163,6 +166,11 @@ export const projectElectronActiveCapture = (
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
"The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
...(!hookSnapshot.events.some(({ kind }) => kind === "preload")
? [
"Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.",
]
: []),
"Process metrics are an Electron API snapshot and do not prove hostile-local-user isolation.",
...(hookSnapshot.dropped > 0
? [

View File

@ -71,6 +71,12 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
let application: ElectronApplication | undefined;
let ownership: OwnedProcessGroup | undefined;
let lineage: ProcessLineageObservation | undefined;
let capture: ElectronActiveObservationResult | undefined;
let paths: ElectronPaths | undefined;
let initialState:
| Awaited<ReturnType<typeof readApplicationState>>
| undefined;
let actions: ElectronActiveObservationResult["actions"] = [];
let outcome: Result<ElectronActiveObservationResult, AnalysisError>;
const runId = randomUUID();
try {
@ -78,7 +84,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
throw new BrowserObservationError(OPERATION, "cancelled");
await prepareProcessOwnershipInspection(options.signal);
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
const paths = await canonicalPaths(input);
paths = await canonicalPaths(input);
const captureBaseline =
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
if (options.signal?.aborted)
@ -109,24 +115,66 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
sweepTokenOwnedProcesses: true,
...(captureBaseline === undefined ? {} : { captureBaseline }),
};
const actions = await runElectronActions(application, input, options);
lineage = await observeOwnedProcessLineage(ownership);
initialState = await readApplicationState(application);
actions = await runElectronActions(application, input, options);
const state = await runWithExecutionLimits(
readApplicationState(application),
options.signal,
undefined,
options.signal?.aborted ? Date.now() + 5_000 : undefined,
);
outcome = ok(projectElectronActiveCapture(paths, actions, state));
capture = projectElectronActiveCapture(paths, actions, state);
outcome = options.signal?.aborted
? err(
new BrowserObservationError(OPERATION, "cancelled", {
partialObservation: {
kind: "electron-active-observation",
capture,
},
}),
)
: ok(capture);
} catch (cause: unknown) {
if (
options.signal?.aborted &&
paths !== undefined &&
initialState !== undefined
) {
const retainedCapture = projectElectronActiveCapture(
paths,
actions,
initialState,
);
capture = {
...retainedCapture,
limitations: [
...retainedCapture.limitations,
`Final Electron state was unavailable after cancellation: ${cause instanceof Error ? cause.message : String(cause)}. Windows, metrics, IPC, and timeline are the snapshot collected before actions and may omit their effects.`,
],
};
}
outcome = err(
options.signal?.aborted === true &&
(cause === options.signal.reason ||
(cause instanceof Error && cause.name === "AbortError"))
? new BrowserObservationError(OPERATION, "cancelled", { cause })
options.signal?.aborted === true
? new BrowserObservationError(OPERATION, "cancelled", {
cause,
...(capture === undefined
? {}
: {
partialObservation: {
kind: "electron-active-observation" as const,
capture,
},
}),
})
: providerError(cause),
);
}
if (application !== undefined) {
if (ownership !== undefined)
lineage = await observeOwnedProcessLineage(ownership);
if (ownership !== undefined) {
const currentLineage = await observeOwnedProcessLineage(ownership);
if (currentLineage.status === "verified" || lineage === undefined)
lineage = currentLineage;
}
let closeError: unknown;
try {
await runWithExecutionLimits(
@ -146,13 +194,51 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
: await cleanupElectronProcesses(ownership, lineage);
if (!cleanup.cleaned)
return err(
providerError(
new BrowserObservationError(OPERATION, "cleanup_failed", {
cause: new Error(cleanup.reason),
}),
),
new BrowserObservationError(OPERATION, "cleanup_failed", {
cause: outcome.ok ? new Error(cleanup.reason) : outcome.error,
detail: `Electron cleanup could not be verified: ${cleanup.reason}.${capture !== undefined ? " Collected observations are retained in partial_observation." : ""}${outcome.ok ? "" : ` Capture also failed: ${outcome.error.message}.`} Inspect the reported process resources before launching another capture.`,
cleanup: {
reason: cleanup.reason,
resources:
ownership === undefined
? ["owned_process_group"]
: [
"owned_process_group",
`run:${ownership.runId}`,
`process-group:${String(ownership.processGroupId)}`,
],
},
...(capture !== undefined
? {
partialObservation: {
kind: "electron-active-observation" as const,
capture: {
...capture,
application: {
...capture.application,
cleanup: "unverified" as const,
},
},
},
}
: {}),
}),
);
if (closeError !== undefined)
return err(
new BrowserObservationError(OPERATION, "cleanup_failed", {
cause: outcome.ok ? closeError : outcome.error,
detail: `Electron transport teardown failed after process cleanup: ${closeError instanceof Error ? closeError.message : String(closeError)}.${capture !== undefined ? " Collected observations are retained in partial_observation." : ""}${outcome.ok ? "" : ` Capture also failed: ${outcome.error.message}.`}`,
...(capture !== undefined
? {
partialObservation: {
kind: "electron-active-observation" as const,
capture,
},
}
: {}),
}),
);
if (closeError !== undefined) return err(providerError(closeError));
if (outcome.ok && cleanup.unverified !== undefined)
outcome = ok({
...outcome.value,
@ -294,5 +380,8 @@ const providerError = (cause: unknown): AnalysisError =>
OPERATION,
{
cause,
diagnostics: {
message: cause instanceof Error ? cause.message : String(cause),
},
},
);

View File

@ -8,6 +8,7 @@ import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
@ -17,6 +18,7 @@ import {
type InspectWebNetworkCaptureInput,
} from "../../domain/webNetworkCapture.js";
import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
import { HAR_CAPTURE_HEAP_LIMITS } from "./CaptureRelease.js";
const OPERATION = "inspect_web_network_capture";
/** Preserve the origin and typed cause of a historical capture failure. */
@ -55,6 +57,18 @@ export const historicalCaptureFailure = (
);
if (cause.reason === "output-limit")
return new AnalysisOutputError(OPERATION, cause.message);
if (
phase === "decoder" &&
input.format === "har" &&
cause.reason === "process" &&
cause.snapshot?.signal === "SIGABRT" &&
/FATAL ERROR:[^\r\n]*heap out of memory/u.test(cause.snapshot.stderr.text)
)
return captureMemoryFailure(
input,
"The HAR decoder exhausted its fixed V8 heap",
cause,
);
}
if (options?.signal?.aborted) return new AnalysisCancelledError(OPERATION);
if (
@ -144,3 +158,44 @@ export const historicalCaptureFailure = (
},
});
};
/** Preserve a decoder's observed memory failure and its actual workload limits. */
export const captureMemoryFailure = (
input: InspectWebNetworkCaptureInput,
reason: string,
cause?: unknown,
): AnalysisResourceConstraintError =>
new AnalysisResourceConstraintError(
OPERATION,
"memory",
`${reason}: ${input.capture_path}. No complete capture result is available.`,
{
boundary: "historical-capture-decoder",
capture_path: input.capture_path,
format: input.format,
maximum_input_bytes: WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
maximum_reply_bytes: WEB_NETWORK_CAPTURE_LIMITS.outputBytes,
...(input.format === "har"
? {
old_generation_heap_mib: HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB,
semi_space_heap_mib: HAR_CAPTURE_HEAP_LIMITS.semiSpaceMiB,
}
: {}),
...(cause instanceof OwnedCommandFailure
? { observed_signal: cause.snapshot?.signal ?? null }
: {}),
},
{
cause,
remediationAction: `Use a smaller capture exported by its producer, or another decoder with sufficient capacity for the original capture. Preserve the original for provenance; a subset does not establish complete-capture coverage.${input.format === "har" ? ` REA's HAR old-generation heap is fixed at ${HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB} MiB; inherited NODE_OPTIONS cannot raise it.` : ""}`,
...(cause instanceof OwnedCommandFailure && cause.snapshot !== null
? {
capturedOutput: {
stdout: cause.snapshot.stdout.text,
stderr: cause.snapshot.stderr.text,
truncated: cause.snapshot.diagnosticTruncated ?? false,
},
}
: {}),
},
);

View File

@ -5,6 +5,12 @@ export const HAR_CAPTURE_PROVIDER_IDENTITY = {
version: "har-schema@2.0.0;jsonc-parser@3.3.1;lossless-json@4.3.1",
} as const;
/** Fixed V8 heap settings used by the owned HAR decoder process. */
export const HAR_CAPTURE_HEAP_LIMITS = {
oldGenerationMiB: 192,
semiSpaceMiB: 8,
} as const;
/** Native executable profile verified with unchanged mitmproxy raw-state decoding. */
export const MITMPROXY_CAPTURE_PROVIDER_IDENTITY = {
id: "mitmproxy-native-tnetstring",

View File

@ -1,6 +1,9 @@
import { fileURLToPath } from "node:url";
import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js";
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "./CaptureRelease.js";
import {
HAR_CAPTURE_PROVIDER_IDENTITY,
HAR_CAPTURE_HEAP_LIMITS,
} from "./CaptureRelease.js";
/** HAR's parser/schema packages run in one bounded Node heap, without network or target execution. */
export class HarCaptureAdapter implements HistoricalCaptureFormatAdapter {
@ -12,8 +15,8 @@ export class HarCaptureAdapter implements HistoricalCaptureFormatAdapter {
return {
command: process.execPath,
arguments: [
"--max-old-space-size=192",
"--max-semi-space-size=8",
`--max-old-space-size=${HAR_CAPTURE_HEAP_LIMITS.oldGenerationMiB}`,
`--max-semi-space-size=${HAR_CAPTURE_HEAP_LIMITS.semiSpaceMiB}`,
"--v8-pool-size=1",
"--single-threaded",
fileURLToPath(new URL("./HarCaptureProcess.js", import.meta.url)),

View File

@ -1,4 +1,7 @@
import { historicalCaptureFailure } from "./CaptureFailures.js";
import {
historicalCaptureFailure,
captureMemoryFailure,
} from "./CaptureFailures.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js";
import { randomUUID } from "node:crypto";
@ -160,7 +163,9 @@ export class HistoricalCaptureDecoder {
},
},
]);
if (reply.reason === "decoder" || reply.reason === "resource-limit")
if (reply.reason === "resource-limit")
throw captureMemoryFailure(input, reply.message);
if (reply.reason === "decoder")
throw new ProviderAdapterError(input.format, OPERATION, {
diagnostics: {
phase: "decoder",

View File

@ -44,7 +44,7 @@ const registerElectronActiveCommand = (
logger: Logger,
): void => {
cli.command(CLI_COMMANDS.captureElectronScenario, {
description: "Run one bounded owned Electron scenario",
description: "Run one owned Electron scenario with cancellable actions",
args: z.object({
inputJson: z
.string()
@ -53,33 +53,40 @@ const registerElectronActiveCommand = (
),
}),
run: ({ args }) =>
logCliCommand(logger, CLI_COMMANDS.captureElectronScenario, async () => {
const input = await parseCliJsonInput(
args.inputJson,
"capture_electron_scenario",
);
if (!input.ok) return input.error;
const parsed = electronActiveObservationInputSchema.safeParse(
resolveCliJsonPaths(input.value, [
["executable_path"],
["application_path"],
["application_root"],
]),
);
if (!parsed.success)
return inputError(
"capture_electron_scenario",
parsed.error.issues,
input.value,
);
const { createElectronScenarioProvider } =
await import("../composition/electronScenario.js");
const result = await captureElectronScenario(
createElectronScenarioProvider(),
parsed.data,
);
return result.ok ? result.value : cliError(result.error);
}),
withCommandCancellation((signal) =>
logCliCommand(
logger,
CLI_COMMANDS.captureElectronScenario,
async () => {
const input = await parseCliJsonInput(
args.inputJson,
"capture_electron_scenario",
);
if (!input.ok) return input.error;
const parsed = electronActiveObservationInputSchema.safeParse(
resolveCliJsonPaths(input.value, [
["executable_path"],
["application_path"],
["application_root"],
]),
);
if (!parsed.success)
return inputError(
"capture_electron_scenario",
parsed.error.issues,
input.value,
);
const { createElectronScenarioProvider } =
await import("../composition/electronScenario.js");
const result = await captureElectronScenario(
createElectronScenarioProvider(),
parsed.data,
{ signal },
);
return result.ok ? result.value : cliError(result.error);
},
),
),
});
};

View File

@ -112,7 +112,7 @@ export const ELECTRON_TOOL_CONTRACTS = [
name: "capture_electron_scenario",
...toolContractMetadata("capture_electron_scenario"),
description:
"Use this for a provider-owned Electron run when passive CDP or Inspector observation cannot exercise application behavior. REA owns startup and teardown, accepts caller-defined click/wait actions plus window-targeted renderer reload/crash and synthetic open-url/second-instance delivery, and returns correlated window/WebContents/process/preload/session/navigation/shell/IPC evidence without retaining payload values. Results identify observed and unavailable event families, coverage status, action targets, and truncation. External shell, navigation, permission, download, popup, updater, and OS-integration effects are blocked and recorded. Use passive Electron tools for observation-only work.",
"Use this for a provider-owned Electron run when passive CDP or Inspector observation cannot exercise application behavior. REA owns startup and teardown, accepts caller-defined click/wait actions plus window-targeted renderer reload/crash and synthetic open-url/second-instance delivery, and returns correlated window/WebContents/process/preload/session/navigation/shell/IPC evidence without retaining payload values. Actions wait for completion or request cancellation; a missing click target can keep the request pending. Results identify observed and unavailable event families, coverage status, action targets, and truncation. Cleanup failures retain collected observations with explicit cleanup uncertainty. External shell, navigation, permission, download, popup, updater, and OS-integration effects are blocked and recorded. Use passive Electron tools for observation-only work.",
kind: "electron-provider",
inputSchema: electronActiveObservationInputSchema,
outputSchema: evidenceResultOf(electronActiveObservationResultSchema),

View File

@ -86,9 +86,11 @@ import type {
} from "./process/processCapture.js";
import type { FileOffsetPartialObservation } from "./native/fileOffsetPartialObservation.js";
import type { NativeCallPartialObservation } from "./native/nativeCallPartialObservation.js";
import type { ElectronActivePartialObservation } from "./javascript/electronActiveObservation.js";
/** Provider-neutral evidence collected before a typed analysis failure. */
export type AnalysisPartialObservation =
| PartialProcessCaptureObservation
| NativeCallPartialObservation
| FileOffsetPartialObservation;
| FileOffsetPartialObservation
| ElectronActivePartialObservation;

View File

@ -96,7 +96,7 @@ export const analysisErrorCategory = (
if (error._tag === "ProcessCaptureError")
return error.userCategory ?? "execution_failure";
if (error instanceof BrowserObservationError)
return browserErrorCategory(error.reason);
return error.userCategory ?? browserErrorCategory(error.reason);
if (
error instanceof HopperRemoteError &&
error.diagnosticType === "invalid_request"
@ -117,6 +117,8 @@ export const analysisErrorCategory = (
const browserErrorCategory = (
reason: BrowserObservationError["reason"],
): AnalysisErrorProjection["category"] => {
if (reason === "cancelled") return "cancelled";
if (reason === "timeout") return "timeout";
if (reason === "payload_limit") return "truncated";
if (
reason === "target_not_found" ||

View File

@ -96,7 +96,9 @@ const underlyingErrorCode = (
? "provider_unavailable"
: "capability_unavailable";
if (error instanceof BrowserObservationError)
return browserErrorCode(error.reason);
return error.userCategory === "cancelled"
? "cancelled"
: browserErrorCode(error.reason);
if (error instanceof ArtifactOperationError)
return artifactOperationCode(error);
if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason);
@ -109,6 +111,8 @@ const underlyingErrorCode = (
const browserErrorCode = (
reason: BrowserObservationError["reason"],
): AnalysisErrorProjection["code"] => {
if (reason === "cancelled") return "cancelled";
if (reason === "timeout") return "provider_timeout";
if (reason === "payload_limit") return "truncated";
if (
reason === "target_not_found" ||

View File

@ -2,7 +2,10 @@ import type {
BrowserObservationFailureReason,
BrowserObservationOperation,
} from "./browserObservationErrors.js";
import { AnalysisError } from "./analysisErrorBase.js";
import {
AnalysisError,
type AnalysisErrorOptions,
} from "./analysisErrorBase.js";
import { AnalysisCancelledError } from "./analysisErrorCore.js";
/** A bounded passive browser observation failed at its CDP boundary. */
@ -16,16 +19,39 @@ export class BrowserObservationError extends AnalysisError {
constructor(
readonly operation: BrowserObservationOperation,
readonly reason: BrowserObservationFailureReason,
options?: ErrorOptions & { readonly detail?: string },
options?: AnalysisErrorOptions & { readonly detail?: string },
) {
super(`Browser observation ${operation} failed: ${reason}`, options);
this.userMessage = options?.detail;
this.cleanupIncomplete = reason === "cleanup_failed";
this.userMessage =
options?.detail ?? observationFailureMessage(operation, reason);
this.cleanupIncomplete =
this.cleanup !== undefined || reason === "cleanup_failed";
this.cleanupResources =
reason === "cleanup_failed" ? ["browser_transport"] : [];
this.cleanup?.resources ??
(reason === "cleanup_failed" ? ["browser_transport"] : []);
this.userCategory =
reason === "cancelled" || options?.cause instanceof AnalysisCancelledError
reason === "cancelled" ||
options?.cause instanceof AnalysisCancelledError ||
(options?.cause instanceof AnalysisError &&
options.cause.userCategory === "cancelled")
? "cancelled"
: undefined;
}
}
const observationFailureMessage = (
operation: BrowserObservationOperation,
reason: BrowserObservationFailureReason,
): string | undefined => {
if (reason === "cancelled")
return `${operation} was cancelled. Collected observations and cleanup status are reported when available.`;
if (reason === "timeout")
return `${operation} exceeded its execution deadline. Review the target's responsiveness and retained observations before retrying.`;
if (reason === "endpoint_unreachable")
return `${operation} could not reach the selected loopback debugging endpoint. Verify that the target process is running with debugging enabled and that the endpoint's port matches it, then retry.`;
if (reason === "target_not_found")
return `${operation} could not find the selected target at the debugging endpoint. Refresh target discovery for that endpoint and retry with a returned target ID.`;
if (reason === "disconnected")
return `${operation} lost its debugging connection. Verify that the target process is still running, refresh target discovery, and retry the observation.`;
return undefined;
};

View File

@ -5,6 +5,7 @@ import {
AnalysisInputError,
AnalysisOutputError,
AnalysisCapabilityUnavailableError,
AnalysisResourceConstraintError,
} from "./analysisErrorCore.js";
import { redactExplicitText } from "./explicitSensitiveValues.js";
import type { JsonValue } from "./jsonValue.js";
@ -118,6 +119,28 @@ export const redactExplicitFailure = (
: { userMessage: text(error.userMessage) }),
},
);
if (error instanceof AnalysisResourceConstraintError)
return new AnalysisResourceConstraintError(
error.operation,
error.resource,
text(error.reason),
error.reportedLimits === null ? null : diagnostics(error.reportedLimits),
{
cause: error,
...(error.remediationAction === undefined
? {}
: { remediationAction: text(error.remediationAction) }),
...(error.capturedOutput === undefined
? {}
: {
capturedOutput: {
stdout: text(error.capturedOutput.stdout),
stderr: text(error.capturedOutput.stderr),
truncated: error.capturedOutput.truncated,
},
}),
},
);
if (error instanceof ProviderCleanupError)
return new ProviderCleanupError(
error.providerId,

View File

@ -1,8 +1,8 @@
import { z } from "zod";
import { isAbsoluteLocalPath } from "../localPath.js";
import { isAbsoluteLocalPath, localPathStringSchema } from "../localPath.js";
const pathInputSchema = z.string().trim().min(1).refine(isAbsoluteLocalPath, {
const pathInputSchema = localPathStringSchema.refine(isAbsoluteLocalPath, {
message:
"Electron executable, application, and root paths must be absolute local filesystem paths (for example /Applications/Electron.app/Contents/MacOS/Electron)",
});
@ -76,7 +76,11 @@ export const electronActiveObservationInputSchema = z.strictObject({
.describe(
"Absolute local filesystem root for application-relative paths; omit to derive it from the application path. Relative paths are rejected.",
),
args: z.array(z.string()).default([]),
args: z
.array(
z.string().regex(/^[^\0]*$/u, "Electron arguments must not contain NUL"),
)
.default([]),
actions: z.array(actionSchema).default([]),
});
export type ElectronActiveObservationInput = z.infer<
@ -309,3 +313,16 @@ export const electronActiveObservationResultSchema = z.strictObject({
export type ElectronActiveObservationResult = z.infer<
typeof electronActiveObservationResultSchema
>;
/** Observations collected before an Electron capture failed its cleanup boundary. */
export interface ElectronActivePartialObservation {
readonly kind: "electron-active-observation";
readonly capture: Omit<ElectronActiveObservationResult, "application"> & {
readonly application: Omit<
ElectronActiveObservationResult["application"],
"cleanup"
> & {
readonly cleanup: "unverified" | "terminated-owned-process";
};
};
}

View File

@ -0,0 +1,115 @@
import { execFile } from "node:child_process";
import { truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../../src/domain/webNetworkCapture.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const cli = fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url));
const entry = JSON.stringify({
startedDateTime: "2026-01-01T00:00:00.000Z",
time: 1,
request: {
method: "GET",
url: "https://example.invalid/data",
httpVersion: "HTTP/1.1",
cookies: [],
headers: [],
queryString: [],
headersSize: -1,
bodySize: 0,
},
response: {
status: 200,
statusText: "OK",
httpVersion: "HTTP/1.1",
cookies: [],
headers: [],
content: { size: 512, mimeType: "text/plain", text: "x".repeat(512) },
redirectURL: "",
headersSize: -1,
bodySize: 512,
},
cache: {},
timings: { send: 0, wait: 1, receive: 0 },
});
const capture = (count: number) =>
`{"log":{"version":"1.2","creator":{"name":"synthetic","version":"1"},"entries":[${Array<string>(count).fill(entry).join(",")}]}}`;
it("reports real HAR heap exhaustion through CLI and MCP and preserves server usability", async () => {
const root = await createTestTempDirectory("rea-har-capacity-");
const path = join(root, "caller-marked.har");
const dense = capture(34_098);
expect(Buffer.byteLength(dense)).toBeLessThan(
WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
);
await writeFile(path, dense);
const { call, client } = await connectLocalToolsMcp();
const response = await call("inspect_web_network_capture", {
capture_path: path,
format: "har",
sensitive_values: ["caller-marked"],
});
expect(response.isError).toBe(true);
expect(response.structuredContent).toMatchObject({
error: {
code: "resource_constraint",
retryable: false,
message: expect.stringContaining("fixed V8 heap"),
remediation: {
action: expect.stringContaining(
"smaller capture exported by its producer",
),
},
details: {
resource: "memory",
reported_limits: {
old_generation_heap_mib: 192,
observed_signal: "SIGABRT",
},
captured_output: {
stderr: expect.stringContaining("heap out of memory"),
truncated: false,
},
},
},
});
expect(JSON.stringify(response)).not.toContain("caller-marked");
expect(JSON.stringify(response)).not.toContain("rea doctor");
await expect(
execute(process.execPath, [
cli,
"inspect-web-network-capture",
path,
"har",
"--json",
]),
).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining('"code": "resource_constraint"'),
});
const small = join(root, "small.har");
await writeFile(small, capture(2));
await client.ping();
const control = await call("inspect_web_network_capture", {
capture_path: small,
format: "har",
});
expect(control.isError, JSON.stringify(control)).not.toBe(true);
expect(control.structuredContent).toMatchObject({
result: { total_records: 2 },
});
await truncate(path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes + 1);
const oversized = await call("inspect_web_network_capture", {
capture_path: path,
format: "har",
});
expect(oversized.structuredContent).toMatchObject({
error: { code: "invalid_request" },
});
}, 60_000);

View File

@ -0,0 +1,126 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("observes a real Node Inspector and releases attachments after cancellation and failures", async () => {
const root = await createTestTempDirectory("rea-live-inspector-");
const fixture = join(root, "probe #百分比 %.cjs");
await writeFile(fixture, "setInterval(() => {}, 25);\n");
const child = spawn(process.execPath, ["--inspect=127.0.0.1:0", fixture], {
stdio: ["ignore", "ignore", "pipe"],
});
const terminate = async () => {
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
await exited;
}
};
onTestFinished(terminate);
let stderr = "";
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const address = /ws:\/\/127\.0\.0\.1:\d+\/[^\s]+/u;
await expect.poll(() => stderr, { timeout: 5000 }).toMatch(address);
const socket = address.exec(stderr)?.[0];
if (socket === undefined) throw new Error("Node omitted its Inspector URL");
const endpoint = `http://127.0.0.1:${new URL(socket).port}`;
const { client, call } = await connectLocalToolsMcp();
const discovery = await call("list_javascript_runtime_targets", {
inspector_endpoint: endpoint,
});
const targets = toolContract(
"list_javascript_runtime_targets",
).outputSchema.parse(discovery.structuredContent);
const id = targets.result.targets[0]?.target_id;
if (id === undefined) throw new Error("Node Inspector target was not listed");
const request = {
inspector_endpoint: endpoint,
target_id: id,
observation_ms: 100,
};
const observed = await call("observe_javascript_runtime", request);
const observation = toolContract(
"observe_javascript_runtime",
).outputSchema.parse(observed.structuredContent);
expect(observation.result.scripts.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
location: expect.objectContaining({ file_path: fixture }),
}),
]),
);
const missing = await call("observe_javascript_runtime", {
...request,
target_id: "../missing-target",
});
expect(missing.isError).toBe(true);
expect(missing.structuredContent).toMatchObject({
error: {
code: "target_unavailable",
message: expect.stringContaining("Refresh target discovery"),
details: { reason: "target_not_found" },
},
});
for (const invalid of [
{
name: "observe_javascript_runtime",
arguments: { ...request, observation_ms: -1 },
},
{
name: "list_javascript_runtime_targets",
arguments: {
inspector_endpoint: endpoint.replace(
"http://",
"http://user:password@",
),
},
},
])
expect((await call(invalid.name, invalid.arguments)).isError).toBe(true);
const attachments = () => (stderr.match(/Debugger attached/gu) ?? []).length;
const detachments = () => (stderr.match(/Debugger ending on/gu) ?? []).length;
const before = attachments();
const controller = new AbortController();
const cancelled = expect(
client.callTool(
{
name: "observe_javascript_runtime",
arguments: { ...request, observation_ms: 10_000 },
},
{ signal: controller.signal },
),
).rejects.toThrow("cancel live observation");
await expect.poll(attachments, { timeout: 5000 }).toBe(before + 1);
controller.abort(new Error("cancel live observation"));
await cancelled;
await expect.poll(detachments, { timeout: 5000 }).toBe(attachments());
expect(child.exitCode).toBeNull();
expect(child.signalCode).toBeNull();
expect((await call("observe_javascript_runtime", request)).isError).not.toBe(
true,
);
await client.ping();
await terminate();
const unreachable = await call("list_javascript_runtime_targets", {
inspector_endpoint: endpoint,
});
expect(unreachable.isError).toBe(true);
expect(unreachable.structuredContent).toMatchObject({
error: {
code: "provider_unavailable",
message: expect.stringContaining(
"target process is running with debugging enabled",
),
details: { reason: "endpoint_unreachable" },
},
});
}, 30_000);

View File

@ -0,0 +1,379 @@
import { execFile } from "node:child_process";
import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { itWithCaptureCapability as captureTest } from "../../boundary/process/processCaptureCapability.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const cli = fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url));
const nul = "/tmp/selected\0filename";
const electron = {
executable_path: "/tmp/Electron",
application_path: "/tmp/main.js",
};
const pathRequests: readonly [string, Record<string, unknown>][] = [
["open_binary", { path: nul }],
["open_binary", { path: process.execPath, snapshot_path: nul }],
["close_binary", { snapshot_path: nul }],
["binary_session", { expected_server_path: nul }],
["export_evidence_bundle", { path: nul }],
["import_evidence_bundle", { path: nul }],
["inspect_managed_artifact", { path: nul }],
["inspect_android_package", { path: nul }],
["search_android_classes", { path: nul, query: "" }],
["inspect_android_class", { path: nul, class_name: "probe.Class" }],
[
"inspect_android_method",
{ path: nul, class_name: "probe.Class", method_name: "run" },
],
["trace_android_references", { path: nul, class_name: "probe.Class" }],
["inspect_firmware_regions", { path: nul }],
["extract_firmware", { path: nul, output_directory: "/tmp/out" }],
["extract_firmware", { path: "/tmp/firmware.bin", output_directory: nul }],
["inspect_plist", { path: nul }],
...[
{ kind: "function", name: "main\0suffix" },
{ kind: "function", name: "main", module: "lib.dylib\0suffix" },
{ kind: "objc-method", selector: "run\0suffix" },
{ kind: "objc-method", class_name: "Probe\0suffix", selector: "run" },
].map((breakpoint): [string, Record<string, unknown>] => [
"observe_native_calls",
{ breakpoints: [breakpoint] },
]),
[
"observe_native_calls",
{
breakpoints: [{ kind: "function", name: "main" }],
working_directory: nul,
},
],
[
"observe_native_calls",
{ breakpoints: [{ kind: "function", name: "main" }], arguments: ["a\0b"] },
],
["analyze_javascript_application", { input_path: nul }],
["recover_javascript_sources", { path: nul, output_directory: "/tmp/out" }],
[
"recover_javascript_sources",
{ path: "/tmp/main.js", output_directory: nul },
],
[
"compare_bundles",
{ left_bundle_path: nul, right_bundle_path: "/tmp/right.json" },
],
[
"compare_bundles",
{ left_bundle_path: "/tmp/left.json", right_bundle_path: nul },
],
[
"capture_browser_scenario",
{
browser: { mode: "launch", executable_path: nul },
start_url: { url: "http://127.0.0.1:1" },
actions: [
{ step_id: "settle", action: "wait_for_timeout", duration_ms: 0 },
],
},
],
...["executable_path", "application_path", "application_root"].map(
(key): [string, Record<string, unknown>] => [
"capture_electron_scenario",
{ ...electron, [key]: nul },
],
),
["capture_electron_scenario", { ...electron, args: ["a\0b"] }],
["demangle_swift", { symbols: ["abc\0def"] }],
];
it("rejects OS-invalid local inputs through the real advertised MCP contracts", async () => {
const { call } = await connectLocalToolsMcp();
for (const [name, arguments_] of pathRequests) {
const response = await call(name, arguments_);
expect(response.isError, name).toBe(true);
expect(JSON.stringify(response.content), name).toContain("NUL");
}
const relative = await call("extract_firmware", {
path: "/tmp/firmware.bin",
output_directory: "relative-output",
});
expect(relative.isError).toBe(true);
expect(JSON.stringify(relative.content)).toContain(
"absolute local filesystem path",
);
});
const ignoredOptions = [
{ terminal: { columns: 80, row: 12 } },
{ events: [{ type: "input", at_ms: 0, data: "x", secret: true }] },
{ events: [{ type: "resize", at_ms: 0, columns: 80, rows: 24, row: 12 }] },
{ events: [{ type: "signal", at_ms: 0, signal: "SIGTERM", pid: 1 }] },
{ normalization: { path: true } },
{ limits: { file_count: 1 } },
{
normalization: {
patterns: [{ pattern: "x", replacement: "y", regex: true }],
},
},
];
captureTest(
"rejects malformed process choices through CLI and MCP before launching the command",
async () => {
const root = await createTestTempDirectory("rea-local-contracts-");
const marker = join(root, "launched");
const scenario = {
executable: process.execPath,
arguments: [
"-e",
"require('node:fs').writeFileSync(process.argv[1],'launched')",
marker,
],
};
const { call } = await connectLocalToolsMcp();
const invalid = [
...ignoredOptions,
...["", "KEY=VALUE", "KEY\0VALUE", "REA_PROCESS_RUN_ID"].map((key) => ({
environment: { [key]: "value" },
})),
{ executable: `node\0` },
{ arguments: ["\0"] },
{ working_directory: "/tmp/dir\0" },
{ environment: { KEY: "\0" } },
{ filesystem_observation_paths: ["\0"] },
{ timeout_ms: 10, events: [{ type: "input", at_ms: 11, data: "late" }] },
{
events: [
{ type: "resize", at_ms: 2, columns: 80, rows: 24 },
{ type: "input", at_ms: 1, data: "out of order" },
],
},
...["command_shims", "replay", "reactive", "checkpoints"].map(
(field) => ({
[field]: [],
}),
),
];
for (const options of invalid) {
const request = { ...scenario, ...options };
expect(
(await call("capture_process_scenario", request)).isError,
JSON.stringify(options),
).toBe(true);
const path = join(root, "scenario.json");
await writeFile(path, JSON.stringify(request));
await expect(
execute(process.execPath, [cli, "capture-process", path, "--json"]),
).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining("invalid_request"),
});
}
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
const missing = join(root, "missing-executable");
const failed = await call("capture_process_scenario", {
executable: missing,
});
expect(failed.structuredContent).toMatchObject({
error: {
code: "process_capture_failed",
message: expect.stringContaining(missing),
details: { execution_failure: expect.stringContaining(missing) },
},
});
await writeFile(
join(root, "scenario.json"),
JSON.stringify({ executable: missing }),
);
await expect(
execute(process.execPath, [
cli,
"capture-process",
join(root, "scenario.json"),
"--json",
]),
).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining(missing),
});
},
60_000,
);
captureTest.each(["unknown", "truncated"] as const)(
"captures real process choices and composes a %s self-comparison",
async (status) => {
const root = await createTestTempDirectory("rea-local-capture-");
const { call } = await connectLocalToolsMcp();
const captured = await call("capture_process_scenario", {
executable: process.execPath,
arguments: [
"-e",
`require('node:fs').writeFileSync('state','selected');console.log(process.env['app.setting'],process.env.MY_PASSWORD,process.env['REA_PROCESS_RUN_ID'+String.fromCharCode(10)]);${status === "truncated" ? "console.log('x'.repeat(5000))" : ""}`,
],
working_directory: root,
environment: {
"app.setting": "value",
"1": "numbered",
MY_PASSWORD: "ordinary-evidence",
"REA_PROCESS_RUN_ID\n": "caller-value",
},
filesystem_observation_paths: [root],
limits: status === "truncated" ? { output_bytes: 128 } : {},
});
expect(captured.isError, JSON.stringify(captured)).not.toBe(true);
const capture = toolContract("capture_process_scenario").outputSchema.parse(
captured.structuredContent,
);
const source = capture.evidence;
if (status === "unknown")
expect(
capture.result.frames.map((frame) => frame.data).join(""),
).toContain("value ordinary-evidence caller-value");
else expect(capture.result.truncated).toBe(true);
const environment = await call("capture_process_scenario", {
executable: "/usr/bin/printenv",
arguments: ["1"],
environment: { "1": "numbered" },
});
const environmentEnvelope = toolContract(
"capture_process_scenario",
).outputSchema.parse(environment.structuredContent);
const received = environmentEnvelope.result.frames
.map((frame) => frame.data)
.join("");
expect(received).toBe("numbered\r\n");
expect(await readFile(join(root, "state"), "utf8")).toBe("selected");
expect(capture.result.filesystem_effects).toEqual(
expect.arrayContaining([expect.objectContaining({ status: "created" })]),
);
expect(capture.result.cleanup).toMatchObject({
owned_process_group: "verified",
temporary_root: "removed",
});
const compared = await call("compare_process_captures", {
left: source,
right: source,
});
expect(compared.isError, JSON.stringify(compared)).not.toBe(true);
const comparison = toolContract(
"compare_process_captures",
).outputSchema.parse(compared.structuredContent);
expect(comparison.result.status).toBe(status);
const verification = await call("verify_reconstruction", {
specification: {
name: "Self comparison retains uncertainty",
claims: [
{
claim_id: "control",
title: "Control capture",
comparison_evidence_id: comparison.evidence_id,
kind: "behavioral",
dimension: "overall",
},
],
},
});
expect(verification.isError, JSON.stringify(verification)).not.toBe(true);
expect(verification.structuredContent).toMatchObject({
result: {
status: "unknown",
claims: {
items: [
{
left_evidence_ids: [source.evidence_id],
right_evidence_ids: [source.evidence_id],
evidence_links: expect.arrayContaining([
source.evidence_id,
comparison.evidence_id,
]),
},
],
},
},
});
const behavior = await call("find_changed_behavior", {
comparisons: [comparison.evidence],
});
expect(behavior.isError, JSON.stringify(behavior)).not.toBe(true);
expect(behavior.structuredContent).toMatchObject({
result: { behavior_status: status },
});
const unknowns = await call("list_unknowns", {
domain: "process-comparison",
});
expect(unknowns.structuredContent).toMatchObject({
result: {
items: [
{
status: "open",
supporting_evidence_ids: [source.evidence_id],
contradicting_evidence_ids: [],
},
],
},
});
const bundle = await call("get_evidence_bundle", {});
expect(
(await call("compare_process_captures", { left: source, right: source }))
.isError,
).not.toBe(true);
expect((await call("get_evidence_bundle", {})).structuredContent).toEqual(
bundle.structuredContent,
);
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(source));
const cliComparison = await execute(process.execPath, [
cli,
"compare-process-captures",
path,
path,
"--json",
]);
expect(parseEvidence(JSON.parse(cliComparison.stdout))).toEqual(
comparison.evidence,
);
},
);
captureTest(
"accepts NUL as actual terminal input while preserving capture defaults",
async () => {
const { call } = await connectLocalToolsMcp();
const response = await call("capture_process_scenario", {
executable: process.execPath,
arguments: [
"-e",
"process.stdin.setRawMode(true);process.stdin.resume();process.stdin.once('data',bytes=>{console.log('stdin-byte:'+bytes[0]);process.exit(0)});console.log('ready')",
],
events: [{ type: "input", at_ms: 1000, data: "\0" }],
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
const capture = toolContract("capture_process_scenario").outputSchema.parse(
response.structuredContent,
);
expect(capture.result.frames.map((frame) => frame.data).join("")).toContain(
"stdin-byte:0",
);
expect(capture.result.manifest.scenario).toMatchObject({
environment: {},
filesystem_observation_paths: [],
terminal: { columns: 80, rows: 24, scrollback: 1000 },
});
expect(capture.result.interaction_events).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "input",
data: "\0",
outcome: "dispatched",
}),
]),
);
},
);

View File

@ -10,6 +10,7 @@ const temporary: string[] = [];
const electronModuleSource = `const { EventEmitter } = require("node:events");
const app = new EventEmitter();
app.isReady = () => true;
app.relaunch = () => undefined;
app.setAsDefaultProtocolClient = () => true;
app.setLoginItemSettings = () => undefined;
@ -40,6 +41,8 @@ class BrowserWindow extends EventEmitter {
this.id = 3;
this.webContents = new WebContents();
this.options = options;
this.webContents.getLastWebPreferences = () => options.webPreferences;
app.emit("browser-window-created", {}, this);
}
}
const utilityProcess = {
@ -66,7 +69,7 @@ const contextBridge = {
};
module.exports = {
app,
BrowserWindow,
get BrowserWindow() { return BrowserWindow; },
contextBridge,
ipcMain,
ipcRenderer,

View File

@ -266,7 +266,7 @@ it("cancels after snapshot acquisition before launch and removes private input d
it.each([
{ reason: "input-limit", tag: "AnalysisInputError" },
{ reason: "resource-limit", tag: "ProviderAdapterError" },
{ reason: "resource-limit", tag: "AnalysisResourceConstraintError" },
{ reason: "decoder", tag: "ProviderAdapterError" },
{ reason: "limit", tag: "AnalysisOutputError" },
])(

62
tests/fixtures/localToolsMcp.ts vendored Normal file
View File

@ -0,0 +1,62 @@
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { Ajv2020 } from "ajv/dist/2020.js";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished } from "vitest";
import { z } from "zod";
/** Start the built production server without configuring deep binary providers. */
export async function connectLocalToolsMcp() {
const transport = new StdioClientTransport({
command: process.execPath,
args: [
fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url)),
"mcp",
],
env: {
PATH: process.env.PATH ?? "/usr/bin:/bin",
REA_LOG_LEVEL: "silent",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
},
stderr: "pipe",
});
const client = new Client({ name: "local-tools-e2e", version: "1" });
onTestFinished(async () => {
await client.close();
await transport.close();
});
await client.connect(transport);
const tools = (await client.listTools()).tools;
const validators = new Map(
tools.flatMap((tool) => {
new Ajv2020({ strict: false, validateFormats: false }).compile(
tool.inputSchema,
);
return tool.outputSchema === undefined
? []
: [
[
tool.name,
new Ajv2020({ strict: false, validateFormats: false }).compile(
z.record(z.string(), z.unknown()).parse(tool.outputSchema),
),
] as const,
];
}),
);
const call = async (name: string, arguments_: Record<string, unknown>) => {
const response = await client.callTool({ name, arguments: arguments_ });
const validate = validators.get(name);
if (
response.isError !== true &&
response.structuredContent !== undefined &&
validate !== undefined
)
expect(
validate(response.structuredContent),
JSON.stringify(validate.errors),
).toBe(true);
return response;
};
return { client, call };
}