refactor(electron): retain complete active observations

This commit is contained in:
morluto committed 2026-09-29 12:42:24 +08:00
1 parent fa2b6c5a61
commit e8c56e0c0a
8 files changed
+60 -161

No files matched your search

-17
View File
@@ -4,21 +4,10 @@ const {
createElectronActiveBoundaryPatches,
} = require("./electron-active-hook-boundaries.cjs");
const boundedEnvironmentNumber = (name, fallback) => {
const value = Number(process.env[name]);
return Number.isInteger(value)
? Math.min(20_000, Math.max(1, value))
: fallback;
};
const MAX_EVENT_NAME_LENGTH = 128;
const MAX_CHANNEL_LENGTH = 1_024;
const MAX_IDENTIFIER_LENGTH = 256;
const MAX_ARGUMENT_SHAPES = 32;
const maxEvents = boundedEnvironmentNumber(
"REA_ELECTRON_ACTIVE_MAX_RUNTIME_EVENTS",
5_000,
);
const ipcEventKinds = new Set([
"main-handler-invocation",
"main-event-invocation",
@@ -34,7 +23,6 @@ const events = [];
let observed = 0;
let observedIpc = 0;
let observedRuntime = 0;
let truncated = false;
let sequence = 0;
let correlationSequence = 0;
@@ -104,10 +92,6 @@ const record = (event) => {
observed += 1;
if (ipcEventKinds.has(event.kind)) observedIpc += 1;
else observedRuntime += 1;
if (events.length >= maxEvents) {
truncated = true;
return null;
}
const raw = {
sequence: ++sequence,
correlation_id:
@@ -496,6 +480,5 @@ globalThis.__reaElectronActiveSnapshot = () => ({
observed,
observed_ipc: observedIpc,
observed_runtime: observedRuntime,
truncated,
hook_error: globalThis.__reaElectronActiveHookError === true,
});
@@ -38,7 +38,6 @@ const scenarioProjection = (
application_root: input.application_root,
args: redactArguments(input.args),
actions: input.actions.map(({ step_id, kind }) => ({ step_id, kind })),
limits: input.limits,
});
const parameters = (
+7 -11
View File
@@ -10,6 +10,7 @@ import type {
} from "../domain/electronActiveObservation.js";
const OPERATION = "capture_electron_scenario" as const;
const ACTION_TIMEOUT_MS = 5_000;
const hookEventSchema = z.strictObject({
sequence: z.number().int().min(1),
@@ -70,11 +71,10 @@ const hookEventSchema = z.strictObject({
});
const hookSnapshotSchema = z.strictObject({
events: z.array(hookEventSchema).max(20_000),
events: z.array(hookEventSchema),
observed: z.number().int().min(0),
observed_ipc: z.number().int().min(0),
observed_runtime: z.number().int().min(0),
truncated: z.boolean(),
hook_error: z.boolean(),
});
@@ -105,7 +105,7 @@ type ElectronPageSnapshot = {
readonly title: string;
};
/** Run bounded, explicit actions against provider-owned Electron windows. */
/** Run explicit actions against provider-owned Electron windows. */
export const runElectronActions = async (
application: ElectronApplication,
input: ElectronActiveObservationInput,
@@ -129,7 +129,7 @@ export const runElectronActions = async (
: await waitForWindow({
application,
windowIndex,
timeout: input.limits.action_timeout_ms,
timeout: ACTION_TIMEOUT_MS,
signal: options.signal,
deadline,
});
@@ -146,7 +146,7 @@ export const runElectronActions = async (
action,
selectedWindow,
remaining,
actionTimeoutMs: input.limits.action_timeout_ms,
actionTimeoutMs: ACTION_TIMEOUT_MS,
options,
deadline,
});
@@ -319,11 +319,10 @@ const emitDeepLink = (
{ delivery: action.delivery, url: action.url },
);
/** Read the current Electron windows, metrics, and bounded hook snapshot. */
/** Read the current Electron windows, metrics, and hook snapshot. */
// oxlint-disable-next-line max-lines-per-function -- one state read must share one capture boundary.
export const readApplicationState = async (
application: ElectronApplication,
maxWindows: number,
): Promise<{
readonly windows: ReadonlyArray<{
readonly window_id: string;
@@ -333,14 +332,13 @@ export const readApplicationState = async (
readonly visible: boolean | null;
readonly destroyed: boolean;
}>;
readonly windowsTruncated: boolean;
readonly metrics: ElectronMetrics;
readonly electronVersion: string;
readonly hookSnapshot: ElectronHookSnapshot;
}> => {
const pages = application.windows();
const pageSnapshotsPromise: Promise<ElectronPageSnapshot[]> = Promise.all(
pages.slice(0, maxWindows).map(
pages.map(
async (page: Page, index): Promise<ElectronPageSnapshot> => ({
index,
url: page.url().slice(0, 65_536),
@@ -397,7 +395,6 @@ export const readApplicationState = async (
observed: 0,
observed_ipc: 0,
observed_runtime: 0,
truncated: false,
hook_error: true,
};
})
@@ -429,7 +426,6 @@ export const readApplicationState = async (
destroyed: metadata.destroyed,
};
}),
windowsTruncated: pages.length > maxWindows,
metrics: processMetrics,
electronVersion,
hookSnapshot,
+11 -44
View File
@@ -41,6 +41,7 @@ import {
} from "./PlaywrightElectronActiveActions.js";
const OPERATION = "capture_electron_scenario" as const;
const RUN_TIMEOUT_MS = 60_000;
/** Public identity for provider-owned Electron runtime experiments. */
export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity =
@@ -136,11 +137,7 @@ const createCoverage = (hookSnapshot: ElectronHookSnapshot) => {
unavailableEventFamilies.add("renderer-ipc");
}
return {
status: hookSnapshot.hook_error
? "hook_conflict"
: hookSnapshot.truncated
? "capture_truncated"
: "partial_attach",
status: hookSnapshot.hook_error ? "hook_conflict" : "partial_attach",
observed_event_families: observedEventFamilies,
unavailable_event_families: [...unavailableEventFamilies].sort(),
observed_roles: observedRoles,
@@ -168,16 +165,12 @@ export class PlaywrightElectronActiveProvider
try {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const deadline = Date.now() + input.limits.max_duration_ms;
const deadline = Date.now() + RUN_TIMEOUT_MS;
const paths = await canonicalPaths(input);
application = await electron.launch({
executablePath: paths.executable,
cwd: paths.root,
env: safeElectronEnvironment(
input.limits.max_ipc_events,
input.limits.max_runtime_events,
runId,
),
env: safeElectronEnvironment(runId),
args: ["-r", hookPath, paths.application, ...input.args],
timeout: Math.max(1, deadline - Date.now()),
});
@@ -206,7 +199,7 @@ export class PlaywrightElectronActiveProvider
deadline,
);
const state = await runWithExecutionLimits(
readApplicationState(application, input.limits.max_windows),
readApplicationState(application),
options.signal,
deadline,
);
@@ -316,7 +309,6 @@ const createResult = (
readonly visible: boolean | null;
readonly destroyed: boolean;
}>;
readonly windowsTruncated: boolean;
readonly metrics: ElectronMetrics;
readonly electronVersion: string;
readonly hookSnapshot: ElectronHookSnapshot;
@@ -335,42 +327,26 @@ const createResult = (
cleanup: "terminated-owned-process",
},
actions,
windows: state.windows.slice(0, input.limits.max_windows),
windows_truncated: state.windowsTruncated,
windows: state.windows,
processes: {
items: state.metrics.slice(0, input.limits.max_processes),
truncated: state.metrics.length > input.limits.max_processes,
items: state.metrics,
},
ipc: {
events: ipcEvents.slice(0, input.limits.max_ipc_events),
events: ipcEvents,
observed: hookSnapshot.observed_ipc,
retained: Math.min(ipcEvents.length, input.limits.max_ipc_events),
truncated:
hookSnapshot.truncated ||
ipcEvents.length > input.limits.max_ipc_events,
},
timeline: {
events: hookSnapshot.events.slice(0, input.limits.max_runtime_events),
events: hookSnapshot.events,
observed: hookSnapshot.observed,
retained: Math.min(
hookSnapshot.events.length,
input.limits.max_runtime_events,
),
truncated:
hookSnapshot.truncated ||
hookSnapshot.events.length > input.limits.max_runtime_events,
},
coverage: createCoverage(hookSnapshot),
limitations: [
"IPC payloads are represented only by bounded value shapes; values are never retained, channels are capped at 1,024 characters, and argument-shape arrays at 32 entries.",
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
"The runtime timeline records bounded lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
"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.",
"Process metrics are an Electron API snapshot and do not prove hostile-local-user isolation.",
"External shell opens, external navigation, permission grants, downloads, popup windows, updater relaunches, and OS integration are blocked and recorded by the active hook; other application filesystem and network behavior is not sandboxed by this provider.",
...(state.windowsTruncated
? ["Windows beyond max_windows were not retained."]
: []),
...(hookSnapshot.hook_error
? ["The active IPC hook could not be installed."]
: []),
@@ -391,11 +367,7 @@ const canonicalPaths = async (
return { executable, application, root };
};
const safeElectronEnvironment = (
maxIpcEvents: number,
maxRuntimeEvents: number,
runId: string,
): Record<string, string> =>
const safeElectronEnvironment = (runId: string): Record<string, string> =>
Object.fromEntries([
...[
"HOME",
@@ -411,11 +383,6 @@ const safeElectronEnvironment = (
.filter(
(entry): entry is readonly [string, string] => entry[1] !== undefined,
),
["REA_ELECTRON_ACTIVE_MAX_IPC_EVENTS", String(maxIpcEvents)] as const,
[
"REA_ELECTRON_ACTIVE_MAX_RUNTIME_EVENTS",
String(maxRuntimeEvents),
] as const,
["REA_PROCESS_RUN_ID", runId] as const,
]);
@@ -1,6 +1,6 @@
import { electronActiveObservationResultSchema } from "./electronActiveObservation.js";
/** Create a bounded active-capture result for MCP contract tests. */
/** Create an active-capture result for MCP contract tests. */
export const createElectronActiveObservationFixtureResult = (
applicationPath: string,
) =>
@@ -33,10 +33,8 @@ export const createElectronActiveObservationFixtureResult = (
destroyed: false,
},
],
windows_truncated: false,
processes: {
items: [{ pid: 1234, type: "Browser", name: null, service_name: null }],
truncated: false,
},
ipc: {
events: [
@@ -51,8 +49,6 @@ export const createElectronActiveObservationFixtureResult = (
},
],
observed: 1,
retained: 1,
truncated: false,
},
limitations: [
"IPC payloads are represented only by bounded value shapes; values are never retained.",
+16 -51
View File
@@ -8,7 +8,7 @@ const absolutePathSchema = z
.max(16_384)
.refine(isAbsolute, "path must be absolute");
const windowIndexSchema = z.number().int().min(0).max(99);
const windowIndexSchema = z.number().int().min(0);
const deepLinkUrlSchema = z
.string()
@@ -32,7 +32,7 @@ const actionSchema = z.discriminatedUnion("kind", [
z.strictObject({
step_id: z.string().min(1).max(128),
kind: z.literal("wait"),
duration_ms: z.number().int().min(0).max(10_000),
duration_ms: z.number().int().min(0),
window_index: windowIndexSchema.optional(),
}),
z.strictObject({
@@ -53,42 +53,14 @@ const actionSchema = z.discriminatedUnion("kind", [
}),
]);
const limitsSchema = z.strictObject({
max_duration_ms: z.number().int().min(1).max(120_000).default(60_000),
action_timeout_ms: z.number().int().min(1).max(30_000).default(5_000),
max_actions: z.number().int().min(1).max(100).default(20),
max_ipc_events: z.number().int().min(1).max(10_000).default(2_000),
max_runtime_events: z.number().int().min(1).max(20_000).default(5_000),
max_processes: z.number().int().min(1).max(100).default(32),
max_windows: z.number().int().min(1).max(100).default(32),
});
/** Input for one explicit, provider-owned Electron runtime experiment. */
export const electronActiveObservationInputSchema = z
.strictObject({
executable_path: absolutePathSchema,
application_path: absolutePathSchema,
application_root: absolutePathSchema,
args: z.array(z.string().max(4_096)).max(32).default([]),
actions: z.array(actionSchema).max(100).default([]),
limits: limitsSchema.default({
max_duration_ms: 60_000,
action_timeout_ms: 5_000,
max_actions: 20,
max_ipc_events: 2_000,
max_runtime_events: 5_000,
max_processes: 32,
max_windows: 32,
}),
})
.superRefine((input, context) => {
if (input.actions.length > input.limits.max_actions)
context.addIssue({
code: "custom",
path: ["actions"],
message: "actions exceed the configured max_actions limit",
});
});
export const electronActiveObservationInputSchema = z.strictObject({
executable_path: absolutePathSchema,
application_path: absolutePathSchema,
application_root: absolutePathSchema,
args: z.array(z.string().max(4_096)).max(32).default([]),
actions: z.array(actionSchema).default([]),
});
export type ElectronActiveObservationInput = z.infer<
typeof electronActiveObservationInputSchema
>;
@@ -222,7 +194,6 @@ const coverageSchema = z.strictObject({
status: z.enum([
"complete",
"partial_attach",
"capture_truncated",
"hook_conflict",
"target_exited",
"cleanup_failed",
@@ -260,7 +231,7 @@ const actionResultSchema = z.discriminatedUnion("status", [
}),
]);
/** Bounded result of a provider-owned Electron runtime experiment. */
/** Result of a provider-owned Electron runtime experiment. */
export const electronActiveObservationResultSchema = z.strictObject({
application: z.strictObject({
executable_path: absolutePathSchema,
@@ -269,27 +240,21 @@ export const electronActiveObservationResultSchema = z.strictObject({
process_ownership: z.literal("provider-owned"),
cleanup: z.literal("terminated-owned-process"),
}),
actions: z.array(actionResultSchema).max(100),
windows: z.array(windowResultSchema).max(100),
windows_truncated: z.boolean(),
actions: z.array(actionResultSchema),
windows: z.array(windowResultSchema),
processes: z.strictObject({
items: z.array(processMetricSchema).max(100),
truncated: z.boolean(),
items: z.array(processMetricSchema),
}),
ipc: z.strictObject({
events: z.array(ipcEventSchema).max(10_000),
events: z.array(ipcEventSchema),
observed: z.number().int().min(0),
retained: z.number().int().min(0),
truncated: z.boolean(),
}),
timeline: z
.strictObject({
events: z.array(timelineEventSchema).max(20_000),
events: z.array(timelineEventSchema),
observed: z.number().int().min(0),
retained: z.number().int().min(0),
truncated: z.boolean(),
})
.default({ events: [], observed: 0, retained: 0, truncated: false }),
.default({ events: [], observed: 0 }),
coverage: coverageSchema.default({
status: "partial_attach",
observed_event_families: [],
@@ -96,14 +96,7 @@ const normalizeInspection = (
completeness: classifyBrowserCompleteness({
policyFilteredSections: new Set(),
attachLimitedSections: new Set(),
truncatedSections: new Set(
result.windows_truncated ||
result.processes.truncated ||
result.ipc.truncated ||
result.timeline.truncated
? ["timeline"]
: [],
),
truncatedSections: new Set(),
unavailableSections: new Set([
"frames",
"scripts",
@@ -118,7 +111,7 @@ const normalizeInspection = (
websocket_connections: 0,
websocket_frames: 0,
webmcp_tools: 0,
timeline_events: result.timeline.truncated ? result.timeline.observed : 0,
timeline_events: 0,
},
}),
});
@@ -8,20 +8,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const temporary: string[] = [];
afterEach(async () => {
await Promise.all(
temporary
.splice(0)
.map(async (path) => rm(path, { force: true, recursive: true })),
);
});
it("keeps active hook lifecycle and IPC evidence bounded at the process boundary", async () => {
const root = await createTestTempDirectory("rea-electron-hook-");
temporary.push(root);
await writeFile(
join(root, "electron.js"),
`const { EventEmitter } = require("node:events");
const electronModuleSource = `const { EventEmitter } = require("node:events");
const app = new EventEmitter();
app.relaunch = () => undefined;
app.setAsDefaultProtocolClient = () => true;
@@ -90,12 +77,9 @@ module.exports = {
shell,
utilityProcess,
};
`,
);
const script = join(root, "exercise.cjs");
await writeFile(
script,
`(async () => {
`;
const activeCaptureSource = `(async () => {
const electron = require("electron");
const window = new electron.BrowserWindow({ webPreferences: { preload: "/approved/preload.js" } });
const channel = "x".repeat(2048);
@@ -115,11 +99,26 @@ electron.shell.openExternal("https://example.invalid").catch(() => undefined);
electron.app.relaunch();
electron.contextBridge.exposeInMainWorld("api", { value: true });
window.webContents.loadURL("https://example.invalid").catch(() => undefined);
for (let index = 0; index < 6000; index++) electron.app.relaunch();
try { process.dlopen({}, "/missing/native.node"); } catch {}
process.stdout.write(JSON.stringify({ snapshot: globalThis.__reaElectronActiveSnapshot(), default_popup_decision: defaultPopupDecision, popup_decision: popupDecision }) + "\\n");
})();
`,
`;
afterEach(async () => {
await Promise.all(
temporary
.splice(0)
.map(async (path) => rm(path, { force: true, recursive: true })),
);
});
it("retains active hook lifecycle and IPC evidence without a count ceiling", async () => {
const root = await createTestTempDirectory("rea-electron-hook-");
temporary.push(root);
await writeFile(join(root, "electron.js"), electronModuleSource);
const script = join(root, "exercise.cjs");
await writeFile(script, activeCaptureSource);
const hook = join(process.cwd(), "scripts/electron-active-hook.cjs");
const output = await runNode(hook, script, root, root);
const result = JSON.parse(output.trim().split("\n").at(-1) ?? "null") as {
@@ -128,11 +127,14 @@ process.stdout.write(JSON.stringify({ snapshot: globalThis.__reaElectronActiveSn
readonly snapshot: {
readonly hook_error: boolean;
readonly events: readonly Record<string, unknown>[];
readonly observed: number;
};
};
const { snapshot } = result;
expect(snapshot.hook_error).toBe(false);
expect(snapshot.events).toHaveLength(snapshot.observed);
expect(snapshot.observed).toBeGreaterThan(5_000);
expect(snapshot.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "window-lifecycle", event: "created" }),
@@ -189,7 +191,6 @@ const runNode = (
env: {
...process.env,
NODE_PATH: moduleRoot,
REA_ELECTRON_ACTIVE_MAX_RUNTIME_EVENTS: "200",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -229,7 +230,6 @@ const runNodeStatus = (
env: {
...process.env,
NODE_PATH: moduleRoot,
REA_ELECTRON_ACTIVE_MAX_RUNTIME_EVENTS: "200",
},
stdio: ["ignore", "pipe", "pipe"],
});