From 3e88e217052df44d9e96d2e2f93fe6ae33262afe Mon Sep 17 00:00:00 2001 From: Harsh Asalkar Date: Thu, 15 Oct 2026 12:27:42 +0530 Subject: [PATCH] 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) --- docs/electron-observation.md | 6 + .../PlaywrightElectronActiveFuses.test.ts | 167 ++++++++++++++++++ src/browser/PlaywrightElectronActiveFuses.ts | 96 ++++++++++ .../PlaywrightElectronActiveProvider.test.ts | 40 ++++- .../PlaywrightElectronActiveProvider.ts | 11 ++ 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 src/browser/PlaywrightElectronActiveFuses.test.ts create mode 100644 src/browser/PlaywrightElectronActiveFuses.ts diff --git a/docs/electron-observation.md b/docs/electron-observation.md index e702978d9..fb5e9be83 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -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, diff --git a/src/browser/PlaywrightElectronActiveFuses.test.ts b/src/browser/PlaywrightElectronActiveFuses.test.ts new file mode 100644 index 000000000..eb641f359 --- /dev/null +++ b/src/browser/PlaywrightElectronActiveFuses.test.ts @@ -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); + } + }); +}); diff --git a/src/browser/PlaywrightElectronActiveFuses.ts b/src/browser/PlaywrightElectronActiveFuses.ts new file mode 100644 index 000000000..a6eaf5ab9 --- /dev/null +++ b/src/browser/PlaywrightElectronActiveFuses.ts @@ -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 => { + 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. + } +}; diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 9bdaaf3cd..b51e216f7 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -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"), + }); +}); diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 68c851352..606ffadb5 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -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)