fix(electron): reject active scenario capture when inspect fuse is disabled (#3453) (#3605)

* 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:
Harsh Asalkar authored and GitHub committed 2026-10-15 15:57:42 +09:00
1 parent d94ef22d70
commit 3e88e21705
5 files changed
+319 -1

No files matched your search

+6
View File
@@ -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)