rea/src/browser/PlaywrightElectronActivePro...

390 lines
13 KiB
TypeScript

import { realpath } from "node:fs/promises";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { _electron as electron } from "playwright-core";
import type {
ExecutionOptions,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { ElectronActiveObservationPort } from "../application/ElectronActiveObservationPort.js";
import {
electronActiveObservationResultSchema,
type ElectronActiveObservationInput,
type ElectronActiveObservationResult,
} from "../domain/electronActiveObservation.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
cleanupOwnedProcessGroup,
cleanupWindowsProcessTree,
verifyNoTokenOwnedProcesses,
type OwnedProcessGroup,
type ProcessCleanupResult,
type ProcessLineageObservation,
} from "../process/ProcessOwnership.js";
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProcessTree.js";
import {
runElectronActions,
readApplicationState,
runWithExecutionLimits,
type ElectronHookEvent,
type ElectronHookSnapshot,
type ElectronMetrics,
} from "./PlaywrightElectronActiveActions.js";
const OPERATION = "capture_electron_scenario" as const;
const STARTUP_TIMEOUT_MS = 60_000;
/** Public identity for provider-owned Electron runtime experiments. */
export const PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY: ProviderIdentity =
Object.freeze({
id: "rea-playwright-electron-active",
name: "REA Playwright active Electron observation provider",
version: "1",
});
const hookPath = fileURLToPath(
new URL("../../scripts/electron-active-hook.cjs", import.meta.url),
);
type ElectronApplication = Awaited<ReturnType<typeof electron.launch>>;
type ElectronPaths = {
readonly executable: string;
readonly application: string;
readonly root: string;
};
const observableEventFamilies = [
"app-lifecycle",
"window-lifecycle",
"web-contents-lifecycle",
"navigation",
"shell-attempt",
"process-lifecycle",
"permission",
"popup-attempt",
"download",
"protocol",
"preload",
"preload-configuration",
"renderer-ipc",
"native-addon",
"updater",
"error",
"ipc",
] as const;
const ipcEventKinds = new Set<ElectronHookEvent["kind"]>([
"main-handler-invocation",
"main-event-invocation",
"utility-process-fork",
"utility-process-message",
"ipc-main-to-renderer",
"ipc-utility-to-main",
"ipc-renderer-send",
"ipc-renderer-invoke",
"ipc-renderer-post-message",
]);
const createCoverage = (hookSnapshot: ElectronHookSnapshot) => {
const observedEventFamilies: string[] = [
...new Set(
hookSnapshot.events.map(({ kind, process_type }) => {
if (kind === "preload" && process_type === "main")
return "preload-configuration";
if (kind.startsWith("ipc-renderer") && process_type !== "renderer")
return "ipc";
return ipcEventKinds.has(kind) ? "ipc" : kind;
}),
),
].sort();
const unavailableEventFamilies = new Set(
observableEventFamilies.filter(
(family) => !observedEventFamilies.includes(family),
),
);
const observedRoles = [
...new Set(
hookSnapshot.events.flatMap(({ process_type, kind }) => [
...(process_type === null ? [] : [process_type]),
...(kind === "window-lifecycle" ? ["window"] : []),
...(kind === "web-contents-lifecycle" || kind === "navigation"
? ["web_contents"]
: []),
...(kind === "preload" && process_type === "preload"
? ["preload"]
: []),
...(kind.startsWith("ipc-renderer") && process_type === "renderer"
? ["renderer"]
: []),
]),
),
].sort();
const rendererContextObserved = hookSnapshot.events.some(
({ process_type }) =>
process_type === "renderer" || process_type === "preload",
);
if (!rendererContextObserved) {
unavailableEventFamilies.add("preload");
unavailableEventFamilies.add("renderer-ipc");
}
return {
status: hookSnapshot.hook_error ? "hook_conflict" : "partial_attach",
observed_event_families: observedEventFamilies,
unavailable_event_families: [...unavailableEventFamilies].sort(),
observed_roles: observedRoles,
pre_capture_activity: "unavailable",
} as const;
};
/** Launch an owned Electron application through the official Playwright API. */
export class PlaywrightElectronActiveProvider implements ElectronActiveObservationPort {
identity(): ProviderIdentity {
return PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY;
}
async capture(
input: ElectronActiveObservationInput,
options: ExecutionOptions = {},
): Promise<Result<ElectronActiveObservationResult, AnalysisError>> {
let application: ElectronApplication | undefined;
let ownership: OwnedProcessGroup | undefined;
let lineage: ProcessLineageObservation | undefined;
let outcome: Result<ElectronActiveObservationResult, AnalysisError>;
const runId = randomUUID();
try {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
const paths = await canonicalPaths(input);
application = await electron.launch({
executablePath: paths.executable,
cwd: paths.root,
env: safeElectronEnvironment(runId),
args: ["-r", hookPath, paths.application, ...input.args],
timeout: Math.max(1, startupDeadline - Date.now()),
});
const leaderPid = application.process().pid;
if (
typeof leaderPid !== "number" ||
!Number.isSafeInteger(leaderPid) ||
leaderPid <= 0
)
throw new BrowserObservationError(
OPERATION,
"process_ownership_unavailable",
);
ownership = {
runId,
leaderPid,
processGroupId: leaderPid,
expectedParentPid: process.pid,
expectedCommand: paths.executable,
sweepTokenOwnedProcesses: true,
};
const actions = await runElectronActions(application, input, options);
const state = await runWithExecutionLimits(
readApplicationState(application),
options.signal,
);
outcome = ok(createResult(paths, input, actions, state));
} catch (cause: unknown) {
outcome = err(providerError(cause));
}
if (application !== undefined) {
if (ownership !== undefined)
lineage = await observeOwnedProcessLineage(ownership);
let closeError: unknown;
try {
await runWithExecutionLimits(
application.close(),
undefined,
Date.now() + 5_000,
);
} catch (cause: unknown) {
closeError = cause;
}
const cleanup =
ownership === undefined
? ({
cleaned: false,
reason: "owned Electron process identity was unavailable",
} satisfies ProcessCleanupResult)
: await cleanupElectronProcesses(ownership, lineage);
if (!cleanup.cleaned)
return err(
providerError(
new BrowserObservationError(OPERATION, "cleanup_failed", {
cause: new Error(cleanup.reason),
}),
),
);
if (closeError !== undefined) return err(providerError(closeError));
}
return outcome;
}
}
const cleanupElectronProcesses = async (
ownership: OwnedProcessGroup,
lineage: ProcessLineageObservation | undefined,
): Promise<ProcessCleanupResult> => {
if (process.platform === "win32") {
const root = await cleanupWindowsProcessTree(ownership.leaderPid);
if (!root.cleaned) return root;
if (lineage?.status !== "verified")
return {
cleaned: false,
reason:
"owned Electron lineage was unavailable; helper cleanup was not proven",
};
for (const descendant of lineage.lineage.descendants) {
const result = await cleanupWindowsProcessTree(descendant.pid);
if (!result.cleaned) return result;
}
return root;
}
if (lineage?.status !== "verified") {
const root = await cleanupOwnedProcessGroup(ownership);
return root.cleaned
? {
cleaned: false,
reason:
"owned Electron lineage was unavailable; helper cleanup was not proven",
}
: root;
}
const groupIds = selectCapturedProcessGroupIds(
ownership.leaderPid,
lineage.lineage.descendants.map(({ pid, processGroupId }) => ({
pid,
process_group_id: processGroupId,
})),
);
let signaled = false;
for (const processGroupId of groupIds) {
const cleanupOwnership: OwnedProcessGroup =
processGroupId === ownership.processGroupId
? { ...ownership, leaderPid: processGroupId, processGroupId }
: {
runId: ownership.runId,
leaderPid: processGroupId,
processGroupId,
};
const result = await cleanupOwnedProcessGroup(cleanupOwnership);
if (!result.cleaned) return result;
signaled ||= result.signaled;
}
const remaining = await verifyNoTokenOwnedProcesses(ownership.runId);
if (!remaining.cleaned) return remaining;
return { cleaned: true, signaled };
};
const createResult = (
paths: ElectronPaths,
input: ElectronActiveObservationInput,
actions: ElectronActiveObservationResult["actions"],
state: {
readonly windows: ReadonlyArray<{
readonly window_id: string;
readonly web_contents_id: string;
readonly url: string;
readonly title: string;
readonly visible: boolean | null;
readonly destroyed: boolean;
}>;
readonly metrics: ElectronMetrics;
readonly electronVersion: string;
readonly hookSnapshot: ElectronHookSnapshot;
},
): ElectronActiveObservationResult => {
const { hookSnapshot } = state;
const ipcEvents = hookSnapshot.events.filter(({ kind }) =>
ipcEventKinds.has(kind),
);
return electronActiveObservationResultSchema.parse({
application: {
executable_path: paths.executable,
application_path: paths.application,
electron_version: state.electronVersion,
process_ownership: "provider-owned",
cleanup: "terminated-owned-process",
},
actions,
windows: state.windows,
processes: {
items: state.metrics,
},
ipc: {
events: ipcEvents,
observed: hookSnapshot.observed_ipc,
},
timeline: {
events: hookSnapshot.events,
observed: hookSnapshot.observed,
},
coverage: createCoverage(hookSnapshot),
limitations: [
"IPC payloads are represented by value shapes; payload values are never retained.",
"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.",
"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.",
...(hookSnapshot.hook_error
? ["The active IPC hook could not be installed."]
: []),
],
});
};
const canonicalPaths = async (
input: ElectronActiveObservationInput,
): Promise<ElectronPaths> => {
const [executable, application] = await Promise.all([
realpath(input.executable_path),
realpath(input.application_path),
]);
const root =
input.application_root === undefined
? dirname(application)
: await realpath(input.application_root);
return { executable, application, root };
};
const safeElectronEnvironment = (runId: string): Record<string, string> =>
Object.fromEntries([
...[
"HOME",
"LANG",
"PATH",
"TMP",
"TMPDIR",
"USER",
"DISPLAY",
"WAYLAND_DISPLAY",
]
.map((name) => [name, process.env[name]] as const)
.filter(
(entry): entry is readonly [string, string] => entry[1] !== undefined,
),
["REA_PROCESS_RUN_ID", runId] as const,
]);
const providerError = (cause: unknown): AnalysisError =>
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
PLAYWRIGHT_ELECTRON_ACTIVE_PROVIDER_IDENTITY.id,
OPERATION,
{
cause,
},
);