diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 08c1984e8a..19e4f3a027 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -147,6 +147,14 @@ window-targeted renderer reload/crash actions, and synthetic `open-url` or WebContents identities, process metrics, and IPC channel and value-shape metadata. Payload values are not retained. +For the exact Playwright launch sequence, the active hook removes only REA's +bootstrap arguments from the application-visible `process.argv`. The vector keeps +the selected entry point and every caller argument, in order, including repeated +hook paths, `.asar` filenames, and debug-like options. The private launch handoff +is consumed before application code runs. If the observed sequence cannot be +matched exactly, argv stays unchanged and capture coverage reports `hook_conflict`; +REA does not guess an entry point or remove arguments by filename patterns. + `window_index` selects from Playwright's current Page list at the start of each action. REA resolves that Page to its corresponding BrowserWindow once; the action's `target` uses the BrowserWindow's capture-scoped `window_id`, matching the window inventory. Reordering either enumeration after selection does not retarget the action. Handle cleanup failures retain the completed action records and remain owned for provider cleanup retry. Run the real fixture verifier with an operator-provided Electron runtime: diff --git a/scripts/electron-active-hook.cjs b/scripts/electron-active-hook.cjs index 5a801101c2..ea8d387dac 100644 --- a/scripts/electron-active-hook.cjs +++ b/scripts/electron-active-hook.cjs @@ -28,6 +28,7 @@ const setAdd = bindBuiltinMethod(Set.prototype.add); const hasOwnProperty = bindBuiltinMethod(Object.prototype.hasOwnProperty); const { isProxy } = require("node:util").types; +const { createHash } = require("node:crypto"); const { EventEmitter: NodeEventEmitter } = require("node:events"); const nodeEventEmitterMethods = Object.create(null); for (const method of [ @@ -1241,6 +1242,59 @@ const boundaries = createElectronActiveBoundaryPatches({ shape, }); +// Admit only this provider's exact launch tail and Playwright's owned prefix. +// Caller arguments remain opaque, including repeated hook paths and debug flags. +const restoreApplicationArgv = () => { + const handoff = process.env.REA_ELECTRON_CAPTURE_ARGV; + if (handoff === undefined) return; + delete process.env.REA_ELECTRON_CAPTURE_ARGV; + const plan = JSON.parse(handoff); + if ( + plan === null || + typeof plan !== "object" || + !Number.isSafeInteger(plan.length) || + plan.length < 3 || + typeof plan.sha256 !== "string" || + !/^[a-f0-9]{64}$/u.test(plan.sha256) + ) + throw new Error("Capture argument handoff is not this hook's launch plan"); + const tailIndex = process.argv.length - plan.length; + const prefixIndex = process.argv[1] === "--no-sandbox" ? 2 : 1; + if ( + tailIndex !== prefixIndex + 2 || + process.argv[prefixIndex] !== "--inspect=0" || + process.argv[prefixIndex + 1] !== "--remote-debugging-port=0" || + process.argv[tailIndex] !== "-r" || + process.argv[tailIndex + 1] !== __filename || + typeof process.argv[tailIndex + 2] !== "string" || + process.argv[tailIndex + 2].length === 0 + ) + throw new Error("Observed argv does not match Playwright's capture prefix"); + const tail = sliceArguments(process.argv, tailIndex); + if ( + createHash("sha256").update(JSON.stringify(tail)).digest("hex") !== + plan.sha256 + ) + throw new Error( + "Observed argv does not match the capture argument handoff", + ); + for (let index = 2; index < tail.length; index++) + process.argv[index - 1] = tail[index]; + process.argv.length = tail.length - 1; +}; + +try { + restoreApplicationArgv(); +} catch (cause) { + globalThis.__reaElectronActiveHookError = true; + recordRuntime( + "error", + cause instanceof Error ? cause.message : String(cause), + "failed", + { target: "active-hook-arguments", error: true }, + ); +} + try { const electron = require("electron"); patchEmitter(electron.app, (event, args) => diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 4c3e10bec5..e3f7ec6a19 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -3,6 +3,8 @@ import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { @@ -30,6 +32,27 @@ it("passes the selected environment and ownership token to Electron launch", asy vi.stubEnv("LANG", "ambient-language"); vi.stubEnv("XAUTHORITY", "/ambient/.Xauthority"); let launchedEnvironment: unknown; + const callerArguments = [ + "--", + "/caller/plugin.asar", + "-r", + "caller-hook.cjs", + "\\server\\share\\".repeat(150), + ]; + const launchPlan = [ + "-r", + fileURLToPath( + new URL("../../scripts/electron-active-hook.cjs", import.meta.url), + ), + process.execPath, + ...callerArguments, + ]; + const handoff = JSON.stringify({ + length: launchPlan.length, + sha256: createHash("sha256") + .update(JSON.stringify(launchPlan)) + .digest("hex"), + }); const provider = new PlaywrightElectronActiveProvider( { LANG: "selected-language", @@ -38,6 +61,7 @@ it("passes the selected environment and ownership token to Electron launch", asy XAUTHORITY: "/selected/display-authorization", WAYLAND_DISPLAY: "selected-wayland", REA_PRIVATE_VALUE: "not-a-launch-setting", + REA_ELECTRON_CAPTURE_ARGV: "forged ambient launch plan", }, async (options) => { launchedEnvironment = options?.env; @@ -48,6 +72,7 @@ it("passes the selected environment and ownership token to Electron launch", asy ...electronActiveObservationInputSchema.parse({ executable_path: process.execPath, application_path: process.execPath, + args: callerArguments, }), application_root: dirname(process.execPath), }); @@ -60,7 +85,9 @@ it("passes the selected environment and ownership token to Electron launch", asy XAUTHORITY: "/selected/display-authorization", WAYLAND_DISPLAY: "selected-wayland", REA_PROCESS_RUN_ID: expect.any(String), + REA_ELECTRON_CAPTURE_ARGV: handoff, }); + expect(handoff.length).toBeLessThan(128); }); it("passes required Windows runtime variables without inheriting unrelated values", () => { diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 8ec944753c..ed9cf23117 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -6,7 +6,7 @@ import { ElectronCaptureCleanup } from "./ElectronCaptureCleanup.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { electronFuseFailure } from "../electron/ElectronFuseFailures.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import type { _electron as electron } from "playwright-core"; @@ -155,11 +155,25 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati await systemProcessOwnershipHost.captureBaseline?.(options.signal); if (options.signal?.aborted) throw new BrowserObservationError(OPERATION, "cancelled"); + const applicationArgs = [ + "-r", + hookPath, + paths.application, + ...input.args, + ]; const launchOptions: Parameters[0] = { executablePath: paths.executable, cwd: paths.root, - env: safeElectronEnvironment(this.environment, runId), - args: ["-r", hookPath, paths.application, ...input.args], + env: { + ...safeElectronEnvironment(this.environment, runId), + REA_ELECTRON_CAPTURE_ARGV: JSON.stringify({ + length: applicationArgs.length, + sha256: createHash("sha256") + .update(JSON.stringify(applicationArgs)) + .digest("hex"), + }), + }, + args: applicationArgs, timeout: Math.max(1, startupDeadline - performance.now()), }; application = await containLaunchRejections(() => { diff --git a/tests/boundary/browser/electronActiveHook.test.ts b/tests/boundary/browser/electronActiveHook.test.ts index 667c555751..573d2faa2d 100644 --- a/tests/boundary/browser/electronActiveHook.test.ts +++ b/tests/boundary/browser/electronActiveHook.test.ts @@ -3324,6 +3324,101 @@ it("preserves fatal process termination after recording uncaught exceptions", as expect(result.stderr).toContain("fatal-hook-secret"); }); +it.each([{ sandboxPrefix: [] }, { sandboxPrefix: ["--no-sandbox"] }])( + "restores the exact application argv with Playwright prefix %j", + async ({ sandboxPrefix }) => { + const root = await createTestTempDirectory("rea-electron-argv-"); + temporary.push(root); + await writeElectronFixture(root); + const hook = join(root, "electron-active-hook.cjs"); + const callerArguments = [ + "--", + "/caller/plugin.asar", + "--config=/caller/app.asar/settings", + "-r", + hook, + `--require=${hook}`, + "--inspect=0", + "--remote-debugging-port=0", + "/caller/electron-active-hook.cjs", + "", + "中文", + ]; + const plan = ["-r", hook, "/selected/app.asar", ...callerArguments]; + const script = join(root, "argv.cjs"); + await writeFile( + script, + ` + const argv = ["/runner/electron", ...${JSON.stringify(sandboxPrefix)}, + "--inspect=0", "--remote-debugging-port=0", ...${JSON.stringify(plan)}]; + process.argv = argv; + const plan = ${JSON.stringify(plan)}; + process.env.REA_ELECTRON_CAPTURE_ARGV = JSON.stringify({ length: plan.length, + sha256: require("node:crypto").createHash("sha256").update(JSON.stringify(plan)).digest("hex") }); + require("./electron-active-hook.cjs"); + console.log(JSON.stringify({ argv: process.argv, same: process.argv === argv, + handoff_present: "REA_ELECTRON_CAPTURE_ARGV" in process.env, + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); + `, + ); + // The bootstrap script establishes Electron's observed argv before loading the hook. + const result = await runNode(script, root, undefined, false); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + argv: ["/runner/electron", "/selected/app.asar", ...callerArguments], + same: true, + handoff_present: false, + hook_error: false, + }); + }, +); + +it.each([ + "absent", + "invalid-json", + "mismatch", + "foreign-hook", + "unknown-prefix", + "no-entry", +])("preserves observed argv for an %s capture handoff", async (mode) => { + const root = await createTestTempDirectory("rea-electron-argv-guard-"); + temporary.push(root); + await writeElectronFixture(root); + const hook = join(root, "electron-active-hook.cjs"); + const plan = ["-r", hook, "/selected/app", "caller-document"]; + const script = join(root, "guard.cjs"); + await writeFile( + script, + ` + const mode = ${JSON.stringify(mode)}; + const plan = ${JSON.stringify(plan)}; + const argv = ["/runner/electron", "--inspect=0", "--remote-debugging-port=0", ...plan]; + process.argv = argv; + if (mode === "unknown-prefix") argv[1] = "--inspect=1234"; + if (mode === "mismatch") argv[argv.length - 1] = "another-document"; + if (mode === "foreign-hook") plan[1] = "/other/electron-active-hook.cjs"; + if (mode === "no-entry") plan.length = 2; + if (mode === "absent") delete process.env.REA_ELECTRON_CAPTURE_ARGV; + else process.env.REA_ELECTRON_CAPTURE_ARGV = mode === "invalid-json" ? "{" : JSON.stringify({ + length: plan.length, + sha256: require("node:crypto").createHash("sha256").update(JSON.stringify(plan)).digest("hex") }); + const before = JSON.stringify(argv); + require("./electron-active-hook.cjs"); + console.log(JSON.stringify({ unchanged: JSON.stringify(process.argv) === before, + same: process.argv === argv, handoff_present: "REA_ELECTRON_CAPTURE_ARGV" in process.env, + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); + `, + ); + const result = await runNode(script, root, undefined, false); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + unchanged: true, + same: true, + handoff_present: false, + hook_error: mode !== "absent", + }); +}); + const writeElectronFixture = async ( root: string, source = electronModuleSource, @@ -3359,6 +3454,7 @@ const runNode = async ( script: string, root: string, sourceHook?: string, + preloadHook = true, ): Promise<{ readonly code: number | null; readonly stdout: string; @@ -3366,11 +3462,15 @@ const runNode = async ( }> => { const hook = await stageHook(root, sourceHook); return new Promise((resolve, reject) => { - const child = spawn(process.execPath, ["-r", hook, script], { - cwd: root, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); + const child = spawn( + process.execPath, + preloadHook ? ["-r", hook, script] : [script], + { + cwd: root, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); const stdout: Buffer[] = []; const stderr: Buffer[] = []; child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));