mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
fix(electron): preserve application argv during active capture (#8492)
This commit is contained in:
1 parent
bafe3784cd
commit
32c253ba31
5 files changed
+211
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user