diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 62ade7095..37c38712c 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -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 diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 01c2041c6..4e91c2f12 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -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 diff --git a/scripts/electron-active-hook-boundaries.cjs b/scripts/electron-active-hook-boundaries.cjs index 81d199a18..4bec74dce 100644 --- a/scripts/electron-active-hook-boundaries.cjs +++ b/scripts/electron-active-hook-boundaries.cjs @@ -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; diff --git a/scripts/electron-active-hook.cjs b/scripts/electron-active-hook.cjs index 8d7766c95..022acd30a 100644 --- a/scripts/electron-active-hook.cjs +++ b/scripts/electron-active-hook.cjs @@ -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 = () => ({ diff --git a/scripts/verify-real-electron.mjs b/scripts/verify-real-electron.mjs index bf6f2a169..177ee8e08 100644 --- a/scripts/verify-real-electron.mjs +++ b/scripts/verify-real-electron.mjs @@ -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`); diff --git a/scripts/verify/electron/tool-contracts.mjs b/scripts/verify/electron/tool-contracts.mjs new file mode 100644 index 000000000..7404238dc --- /dev/null +++ b/scripts/verify/electron/tool-contracts.mjs @@ -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"), + '', + ); + 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)); + } + } +} diff --git a/src/browser/PlaywrightElectronActiveActions.ts b/src/browser/PlaywrightElectronActiveActions.ts index bb1469e46..46cb72c25 100644 --- a/src/browser/PlaywrightElectronActiveActions.ts +++ b/src/browser/PlaywrightElectronActiveActions.ts @@ -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; export type ElectronHookEvent = z.infer; export type ElectronMetrics = z.infer[]; type ElectronWindowMetadata = z.infer; -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 => { 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 = Promise.all( - pages.map(async (page: Page, index): Promise => ({ - index, - url: page.url(), - title: await page.title(), - })), - ); const windowMetadataPromise: Promise = 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, diff --git a/src/browser/PlaywrightElectronActiveProjection.ts b/src/browser/PlaywrightElectronActiveProjection.ts index d105d5606..e7b79d633 100644 --- a/src/browser/PlaywrightElectronActiveProjection.ts +++ b/src/browser/PlaywrightElectronActiveProjection.ts @@ -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 ? [ diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 590376955..2e1dd8b8e 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -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> + | undefined; + let actions: ElectronActiveObservationResult["actions"] = []; let outcome: Result; 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), + }, }, ); diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index fec40d549..14b9858e7 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -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, + }, + } + : {}), + }, + ); diff --git a/src/browser/history/CaptureRelease.ts b/src/browser/history/CaptureRelease.ts index 4463befe9..e9ac3e366 100644 --- a/src/browser/history/CaptureRelease.ts +++ b/src/browser/history/CaptureRelease.ts @@ -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", diff --git a/src/browser/history/HarCaptureAdapter.ts b/src/browser/history/HarCaptureAdapter.ts index b66167a5f..bae33fa7e 100644 --- a/src/browser/history/HarCaptureAdapter.ts +++ b/src/browser/history/HarCaptureAdapter.ts @@ -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)), diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index e180ad6ad..bb6062501 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -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", diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index c24fd472a..89dc40eb6 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -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); + }, + ), + ), }); }; diff --git a/src/contracts/javascript/electronToolContracts.ts b/src/contracts/javascript/electronToolContracts.ts index 7c6897689..c0e860f95 100644 --- a/src/contracts/javascript/electronToolContracts.ts +++ b/src/contracts/javascript/electronToolContracts.ts @@ -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), diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 4ba3bd991..3b9087390 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -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; diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 215cf58bd..cc3dc7e40 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -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" || diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 39509368a..66e5c77e9 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -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" || diff --git a/src/domain/browserObservationError.ts b/src/domain/browserObservationError.ts index e698a97ba..439995aeb 100644 --- a/src/domain/browserObservationError.ts +++ b/src/domain/browserObservationError.ts @@ -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; +}; diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index f26324f9b..dd7a14366 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -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, diff --git a/src/domain/javascript/electronActiveObservation.ts b/src/domain/javascript/electronActiveObservation.ts index 7a3fd9af6..be5092065 100644 --- a/src/domain/javascript/electronActiveObservation.ts +++ b/src/domain/javascript/electronActiveObservation.ts @@ -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 & { + readonly application: Omit< + ElectronActiveObservationResult["application"], + "cleanup" + > & { + readonly cleanup: "unverified" | "terminated-owned-process"; + }; + }; +} diff --git a/tests/acceptance/tools/historicalCaptureResources.test.ts b/tests/acceptance/tools/historicalCaptureResources.test.ts new file mode 100644 index 000000000..b0e07f353 --- /dev/null +++ b/tests/acceptance/tools/historicalCaptureResources.test.ts @@ -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(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); diff --git a/tests/acceptance/tools/inspectorLifecycle.test.ts b/tests/acceptance/tools/inspectorLifecycle.test.ts new file mode 100644 index 000000000..91c0b8f34 --- /dev/null +++ b/tests/acceptance/tools/inspectorLifecycle.test.ts @@ -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); diff --git a/tests/acceptance/tools/localToolContracts.test.ts b/tests/acceptance/tools/localToolContracts.test.ts new file mode 100644 index 000000000..41e2ecd10 --- /dev/null +++ b/tests/acceptance/tools/localToolContracts.test.ts @@ -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][] = [ + ["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] => [ + "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] => [ + "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", + }), + ]), + ); + }, +); diff --git a/tests/boundary/browser/electronActiveHook.test.ts b/tests/boundary/browser/electronActiveHook.test.ts index f4cd1e19f..ab6596d82 100644 --- a/tests/boundary/browser/electronActiveHook.test.ts +++ b/tests/boundary/browser/electronActiveHook.test.ts @@ -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, diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts index 5187d1f6a..23c4604a3 100644 --- a/tests/boundary/browser/historicalCaptureDecoder.test.ts +++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts @@ -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" }, ])( diff --git a/tests/fixtures/localToolsMcp.ts b/tests/fixtures/localToolsMcp.ts new file mode 100644 index 000000000..53a84b6c9 --- /dev/null +++ b/tests/fixtures/localToolsMcp.ts @@ -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) => { + 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 }; +}