* feat(electron): add EnableNodeCliInspectArguments fuse pre-flight helper * fix(electron): reject active scenario capture when inspect fuse is disabled (#3453) * docs(electron): document inspect fuse pre-flight refusal for scenario capture (#3453) * test(electron): add PE, ELF, and abort-signal coverage for fuse pre-flight (#3453) * fix(electron): resolve macOS framework binary and enforce startup deadline on fuse preflight (#3453)
This commit is contained in:
1 parent
d94ef22d70
commit
3e88e21705
5 files changed
+319
-1
No files matched your search
@@ -116,6 +116,12 @@ If cleanup cannot be verified, the error retains collected observations in
|
||||
`details.partial_observation` and marks their application cleanup `unverified`.
|
||||
Cleanup uncertainty does not authorize signaling unrelated processes.
|
||||
|
||||
### Target fuse prerequisites
|
||||
|
||||
Active scenario capture requires that the target Electron executable honors Node CLI inspector flags (`--inspect` or `--remote-debugging-port`) so that Playwright can establish its DevTools protocol session.
|
||||
|
||||
When an Electron executable explicitly disables the `EnableNodeCliInspectArguments` fuse (or removes the capability), `capture-electron-scenario` fails immediately before process launch with an `AnalysisUnsupportedTargetError` ("unsupported_target") rather than waiting for the startup deadline. For targets with disabled inspector fuses, use passive CDP inspection via `inspect_electron_page` or run against an unrestricted development binary with the extracted `app.asar`.
|
||||
|
||||
The real verifier exercises compiled CLI and stdio MCP discovery, page/script
|
||||
inspection, static/runtime reconciliation, active observation, and CLI SIGTERM
|
||||
cleanup before checking the IPC, renderer crash/reload, utility/child-process,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { ELECTRON_FUSE_STATE_BYTES } from "../domain/electron/electronFuses.js";
|
||||
import {
|
||||
elfFuseImage,
|
||||
OBSIDIAN_FUSE_BYTES,
|
||||
peFuseImage,
|
||||
thinFuseImage,
|
||||
thinMacho,
|
||||
universalFuseImage,
|
||||
} from "../../tests/fixtures/electron/fuseImage.js";
|
||||
import { AnalysisUnsupportedTargetError } from "../domain/analysisErrorCore.js";
|
||||
import { assertNodeCliInspectArgumentsSupported } from "./PlaywrightElectronActiveFuses.js";
|
||||
|
||||
describe("assertNodeCliInspectArgumentsSupported", () => {
|
||||
it("rejects thin Mach-O binary when EnableNodeCliInspectArguments fuse is disabled", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-preflight-");
|
||||
const binaryPath = join(root, "disabled-app");
|
||||
const image = thinFuseImage(OBSIDIAN_FUSE_BYTES); // index 3 is disabled
|
||||
await writeFile(binaryPath, image.bytes);
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(binaryPath),
|
||||
).rejects.toThrow(AnalysisUnsupportedTargetError);
|
||||
|
||||
try {
|
||||
await assertNodeCliInspectArgumentsSupported(binaryPath);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AnalysisUnsupportedTargetError);
|
||||
const targetError = error as AnalysisUnsupportedTargetError;
|
||||
expect(targetError.operation).toBe("capture_electron_scenario");
|
||||
expect(targetError.path).toBe(binaryPath);
|
||||
expect(targetError.message).toContain("EnableNodeCliInspectArguments");
|
||||
expect(targetError.remediationAction).toContain("inspect_electron_page");
|
||||
}
|
||||
});
|
||||
|
||||
it("passes when EnableNodeCliInspectArguments fuse is enabled", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-preflight-");
|
||||
const binaryPath = join(root, "enabled-app");
|
||||
const enabledFuses = [...OBSIDIAN_FUSE_BYTES];
|
||||
enabledFuses[3] = ELECTRON_FUSE_STATE_BYTES.enabled;
|
||||
const image = thinFuseImage(enabledFuses);
|
||||
await writeFile(binaryPath, image.bytes);
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(binaryPath),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes when binary contains no fuse sentinel (fuse state unknown)", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-preflight-");
|
||||
const binaryPath = join(root, "stock-node");
|
||||
const image = thinMacho(0x0100000c, Buffer.from("no-sentinel-bytes-here"));
|
||||
await writeFile(binaryPath, image);
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(binaryPath),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes gracefully when fuse reader throws a format or reading failure", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-preflight-");
|
||||
const nonMachoPath = join(root, "script.sh");
|
||||
await writeFile(nonMachoPath, "#!/bin/sh\necho hello");
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(nonMachoPath),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects Windows PE binary when EnableNodeCliInspectArguments fuse is disabled", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-preflight-");
|
||||
const binaryPath = join(root, "disabled-pe.exe");
|
||||
const image = peFuseImage(OBSIDIAN_FUSE_BYTES);
|
||||
await writeFile(binaryPath, image.bytes);
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(binaryPath),
|
||||
).rejects.toThrow(AnalysisUnsupportedTargetError);
|
||||
});
|
||||
|
||||
it("rejects Linux ELF binary when EnableNodeCliInspectArguments fuse is disabled", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-preflight-");
|
||||
const binaryPath = join(root, "disabled-elf");
|
||||
const image = elfFuseImage(OBSIDIAN_FUSE_BYTES);
|
||||
await writeFile(binaryPath, image.bytes);
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(binaryPath),
|
||||
).rejects.toThrow(AnalysisUnsupportedTargetError);
|
||||
});
|
||||
|
||||
it("rethrows when signal is aborted during reading", async () => {
|
||||
const controller = new AbortController();
|
||||
const readFuseImage = vi.fn(async () => {
|
||||
controller.abort();
|
||||
throw new Error("aborted during read");
|
||||
});
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported("/nonexistent/binary", {
|
||||
signal: controller.signal,
|
||||
readFuseImage,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(readFuseImage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects macOS app bundle when framework binary has disabled inspect fuse", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-mac-bundle-");
|
||||
const appBundle = join(root, "Obsidian.app");
|
||||
const launcherBinary = join(appBundle, "Contents", "MacOS", "Obsidian");
|
||||
const frameworkDir = join(
|
||||
appBundle,
|
||||
"Contents",
|
||||
"Frameworks",
|
||||
"Electron Framework.framework",
|
||||
"Versions",
|
||||
"A",
|
||||
);
|
||||
await mkdir(join(appBundle, "Contents", "MacOS"), { recursive: true });
|
||||
await mkdir(frameworkDir, { recursive: true });
|
||||
|
||||
// Launcher binary has no sentinel:
|
||||
await writeFile(launcherBinary, Buffer.from("dummy launcher binary"));
|
||||
|
||||
// Framework binary has the disabled fuse wire:
|
||||
const frameworkBinary = join(frameworkDir, "Electron Framework");
|
||||
await writeFile(frameworkBinary, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes);
|
||||
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(launcherBinary),
|
||||
).rejects.toThrow(AnalysisUnsupportedTargetError);
|
||||
|
||||
try {
|
||||
await assertNodeCliInspectArgumentsSupported(launcherBinary);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AnalysisUnsupportedTargetError);
|
||||
const targetError = error as AnalysisUnsupportedTargetError;
|
||||
expect(targetError.operation).toBe("capture_electron_scenario");
|
||||
expect(targetError.path).toBe(launcherBinary);
|
||||
expect(targetError.message).toContain("EnableNodeCliInspectArguments");
|
||||
}
|
||||
});
|
||||
|
||||
it("evaluates matching architecture slice in universal Mach-O binary", async () => {
|
||||
const root = await createTestTempDirectory("rea-fuse-preflight-");
|
||||
const binaryPath = join(root, "universal-app");
|
||||
// universalFuseImage has left (x86_64) disabled, right (arm64) enabled
|
||||
const image = universalFuseImage();
|
||||
await writeFile(binaryPath, image.bytes);
|
||||
|
||||
if (process.arch === "arm64") {
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(binaryPath),
|
||||
).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(
|
||||
assertNodeCliInspectArgumentsSupported(binaryPath),
|
||||
).rejects.toThrow(AnalysisUnsupportedTargetError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { basename, dirname } from "node:path";
|
||||
|
||||
import { AnalysisUnsupportedTargetError } from "../domain/analysisErrorCore.js";
|
||||
import { resolveElectronFuseTarget } from "../electron/ElectronFusePath.js";
|
||||
import { readElectronFuseImage } from "../electron/ElectronFuseReader.js";
|
||||
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const NODE_CLI_INSPECT_ARGUMENTS_FUSE_INDEX = 3;
|
||||
|
||||
export interface InspectArgumentsPreflightOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly readFuseImage?: typeof readElectronFuseImage;
|
||||
readonly resolveFuseTarget?: typeof resolveElectronFuseTarget;
|
||||
}
|
||||
|
||||
const appBundleDirectoryOf = (executablePath: string): string | null => {
|
||||
let current = dirname(executablePath);
|
||||
while (true) {
|
||||
if (basename(current).endsWith(".app")) return current;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-flight verification for active Electron scenario capture.
|
||||
*
|
||||
* Playwright attaches to Electron processes using Node CLI inspect arguments
|
||||
* (--inspect=0 or --remote-debugging-port=0). When an Electron executable disables
|
||||
* the EnableNodeCliInspectArguments fuse, the runtime silently ignores inspect
|
||||
* flags and Playwright hangs until the 60-second startup deadline expires.
|
||||
*
|
||||
* When the fuse wire is present and index 3 is explicitly disabled or removed,
|
||||
* this check fast-fails with an AnalysisUnsupportedTargetError before any child
|
||||
* process is launched. When the fuse state is unknown (missing sentinel, wire
|
||||
* version != 1, or unsupported container format), launch proceeds normally.
|
||||
*/
|
||||
export const assertNodeCliInspectArgumentsSupported = async (
|
||||
executablePath: string,
|
||||
options?: InspectArgumentsPreflightOptions,
|
||||
): Promise<void> => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const reader = options?.readFuseImage ?? readElectronFuseImage;
|
||||
const resolver = options?.resolveFuseTarget ?? resolveElectronFuseTarget;
|
||||
try {
|
||||
let targetPath = executablePath;
|
||||
const bundleDir = appBundleDirectoryOf(executablePath);
|
||||
if (bundleDir !== null) {
|
||||
try {
|
||||
const resolved = await resolver(bundleDir, options?.signal);
|
||||
targetPath = resolved.file.path;
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted) throw cause;
|
||||
targetPath = executablePath;
|
||||
}
|
||||
}
|
||||
|
||||
const image = await reader(
|
||||
targetPath,
|
||||
options?.signal !== undefined ? { signal: options.signal } : undefined,
|
||||
);
|
||||
if (image.slices.length === 0) return;
|
||||
|
||||
const hostArchitecture = process.arch === "x64" ? "x86_64" : process.arch;
|
||||
const targetSlice =
|
||||
image.slices.find((slice) => slice.architecture === hostArchitecture) ??
|
||||
image.slices[0];
|
||||
|
||||
if (targetSlice === undefined) return;
|
||||
|
||||
for (const sentinel of targetSlice.sentinels) {
|
||||
if (sentinel.wire_version !== 1) continue;
|
||||
const fuse = sentinel.fuses.find(
|
||||
(entry) =>
|
||||
entry.index === NODE_CLI_INSPECT_ARGUMENTS_FUSE_INDEX ||
|
||||
entry.name === "EnableNodeCliInspectArguments",
|
||||
);
|
||||
if (fuse?.state === "disabled" || fuse?.state === "removed") {
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
OPERATION,
|
||||
executablePath,
|
||||
"Target binary disables the EnableNodeCliInspectArguments fuse; Node CLI inspector arguments are ignored and active scenario capture cannot attach.",
|
||||
{
|
||||
remediationAction:
|
||||
"Use passive CDP observation tools (inspect_electron_page), or launch active capture against an Electron development build or a stock Electron runtime with the extracted app.",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisUnsupportedTargetError) throw cause;
|
||||
options?.signal?.throwIfAborted();
|
||||
// Missing sentinels, format failures, or unreadable fuses leave inspection capability unknown; proceed with launch.
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,12 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { dirname } from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
OBSIDIAN_FUSE_BYTES,
|
||||
thinFuseImage,
|
||||
} from "../../tests/fixtures/electron/fuseImage.js";
|
||||
|
||||
import * as processOwnership from "../process/ProcessOwnership.js";
|
||||
import * as electronActions from "./PlaywrightElectronActiveActions.js";
|
||||
@@ -253,3 +260,34 @@ it("retains the initial snapshot when the final state read fails", async () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects active capture before launch when EnableNodeCliInspectArguments fuse is disabled", async () => {
|
||||
const root = await createTestTempDirectory("rea-active-provider-fuse-");
|
||||
const disabledBinary = join(root, "disabled-electron");
|
||||
await writeFile(disabledBinary, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes);
|
||||
|
||||
const launchSpy = vi.fn(async () => {
|
||||
throw new Error("launch should not be called when fuse is disabled");
|
||||
});
|
||||
|
||||
const provider = new PlaywrightElectronActiveProvider({}, launchSpy);
|
||||
|
||||
const result = await provider.capture({
|
||||
...electronActiveObservationInputSchema.parse({
|
||||
executable_path: disabledBinary,
|
||||
application_path: disabledBinary,
|
||||
}),
|
||||
application_root: root,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected capture to fail");
|
||||
expect(launchSpy).not.toHaveBeenCalled();
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisUnsupportedTargetError",
|
||||
operation: "capture_electron_scenario",
|
||||
path: disabledBinary,
|
||||
message: expect.stringContaining("EnableNodeCliInspectArguments"),
|
||||
remediationAction: expect.stringContaining("inspect_electron_page"),
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
runWithExecutionLimits,
|
||||
} from "./PlaywrightElectronActiveActions.js";
|
||||
import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js";
|
||||
import { readElectronFuseImage } from "../electron/ElectronFuseReader.js";
|
||||
import { assertNodeCliInspectArgumentsSupported } from "./PlaywrightElectronActiveFuses.js";
|
||||
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
@@ -61,6 +63,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
||||
// Load Playwright only when a capture launches; MCP startup stays off its module graph.
|
||||
private readonly launch: typeof electron.launch = async (options) =>
|
||||
(await import("playwright-core"))._electron.launch(options),
|
||||
private readonly readFuseImage: typeof readElectronFuseImage = readElectronFuseImage,
|
||||
) {}
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
@@ -92,6 +95,14 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
||||
application: input.application_path,
|
||||
root: input.application_root,
|
||||
};
|
||||
await runWithExecutionLimits(
|
||||
assertNodeCliInspectArgumentsSupported(paths.executable, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
readFuseImage: this.readFuseImage,
|
||||
}),
|
||||
options.signal,
|
||||
startupDeadline,
|
||||
);
|
||||
const captureBaseline =
|
||||
await systemProcessOwnershipHost.captureBaseline?.(options.signal);
|
||||
if (options.signal?.aborted)
|
||||
|
||||
Reference in new issue
Block a user