fix(electron): preserve application argv during active capture (#8492)

This commit is contained in:
ShuaixinHuang authored and GitHub committed 2026-11-01 21:23:46 +08:00
1 parent bafe3784cd
commit 32c253ba31
5 files changed
+211 -8

No files matched your search

+8
View File
@@ -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:
+54
View File
@@ -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) =>
@@ -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", () => {
@@ -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<typeof electron.launch>[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(() => {
@@ -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));