From 743251a5d3297fff66189e76a8e8b9f7554e5a63 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 12:38:24 -0700 Subject: [PATCH] FN-9099: harden Hermes Windows CLI launches Reliably resolve and safely launch operator-installed Hermes binaries on Windows. - Resolve Windows Hermes shims using PATH and PATHEXT-aware lookup with bounded caching - Launch .cmd and .bat shims through escaped cmd.exe payloads while preserving direct executable launches - Supervise CLI turns and cover Windows shim, probe, cache, timeout, and injection behavior Files changed: .changeset/fn-9099-hermes-windows-launch.md | 7 + plugins/fusion-plugin-hermes-runtime/README.md | 4 + plugins/fusion-plugin-hermes-runtime/package.json | 1 + .../src/__tests__/cli-spawn.test.ts | 139 +++++++++++++++- .../src/__tests__/probe.test.ts | 61 +++++++ .../src/__tests__/windows-binary-launch.test.ts | 64 +++++++ .../fusion-plugin-hermes-runtime/src/cli-spawn.ts | 72 ++++---- plugins/fusion-plugin-hermes-runtime/src/index.ts | 8 + plugins/fusion-plugin-hermes-runtime/src/probe.ts | 19 ++- .../src/windows-binary-launch.ts | 184 +++++++++++++++++++++ pnpm-lock.yaml | 3 + 11 files changed, 508 insertions(+), 54 deletions(-) Fusion-Task-Id: FN-9099 Fusion-Task-Lineage: 37b1f255-07ce-4952-aac6-1556890849be Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-9099-hermes-windows-launch.md | 7 + .../fusion-plugin-hermes-runtime/README.md | 4 + .../fusion-plugin-hermes-runtime/package.json | 1 + .../src/__tests__/cli-spawn.test.ts | 139 ++++++++++++- .../src/__tests__/probe.test.ts | 61 ++++++ .../__tests__/windows-binary-launch.test.ts | 64 ++++++ .../src/cli-spawn.ts | 72 +++---- .../fusion-plugin-hermes-runtime/src/index.ts | 8 + .../fusion-plugin-hermes-runtime/src/probe.ts | 19 +- .../src/windows-binary-launch.ts | 184 ++++++++++++++++++ pnpm-lock.yaml | 3 + 11 files changed, 508 insertions(+), 54 deletions(-) create mode 100644 .changeset/fn-9099-hermes-windows-launch.md create mode 100644 plugins/fusion-plugin-hermes-runtime/src/__tests__/windows-binary-launch.test.ts create mode 100644 plugins/fusion-plugin-hermes-runtime/src/windows-binary-launch.ts diff --git a/.changeset/fn-9099-hermes-windows-launch.md b/.changeset/fn-9099-hermes-windows-launch.md new file mode 100644 index 0000000000..37ca22aa66 --- /dev/null +++ b/.changeset/fn-9099-hermes-windows-launch.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Reliably launch operator-installed Hermes Windows CLI shims. +category: fix +dev: Adds resolveHermesLaunch/resolveHermesBinaryPath and supervises Hermes prompt turns with superviseSpawn. diff --git a/plugins/fusion-plugin-hermes-runtime/README.md b/plugins/fusion-plugin-hermes-runtime/README.md index 6befc0967e..81d44239c5 100644 --- a/plugins/fusion-plugin-hermes-runtime/README.md +++ b/plugins/fusion-plugin-hermes-runtime/README.md @@ -29,6 +29,10 @@ After install, run `hermes login` (or `hermes auth`) to configure a provider. Th Verify with `hermes --version`. +## Windows binary resolution + +On Windows, `binaryPath` / `HERMES_BIN` can point to an absolute `.cmd` or `.bat` shim. Fusion launches these shims through `cmd.exe /d /s /c` with escaped arguments, rather than relying on an unsafe blanket shell. PATH lookup chooses the first matching directory and then its PATHEXT-preferred executable; results are cached per PATH/PATHEXT, while misses are retried after a short TTL. Probe results continue to report the selected Hermes executable path (not `cmd.exe`) on every platform. + ## Fusion skill auto-install When the Hermes runtime plugin loads, it attempts to auto-install/mirror Fusion's bundled `fusion` skill into the active Hermes profile skill directory: diff --git a/plugins/fusion-plugin-hermes-runtime/package.json b/plugins/fusion-plugin-hermes-runtime/package.json index 145b4ad94e..c418a039a8 100644 --- a/plugins/fusion-plugin-hermes-runtime/package.json +++ b/plugins/fusion-plugin-hermes-runtime/package.json @@ -23,6 +23,7 @@ "test": "vitest run --silent=passed-only --reporter=dot" }, "dependencies": { + "@fusion/core": "workspace:*", "@fusion/plugin-sdk": "workspace:*" }, "devDependencies": { diff --git a/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts b/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts index b3bbccfa23..f7acbf9764 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts @@ -4,9 +4,19 @@ import { EventEmitter } from "node:events"; // ── Mock node:child_process before imports that use it ───────────────────── -const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); +const { mockSpawn, mockSuperviseSpawn } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockSuperviseSpawn: vi.fn(), +})); vi.mock("node:child_process", () => ({ spawn: mockSpawn })); +vi.mock("@fusion/core", () => ({ + superviseSpawn: (command: string, args: string[], options: Record) => { + mockSuperviseSpawn(command, args, options); + const child = mockSpawn(command, args, options); + return { child, kill: (signal: NodeJS.Signals) => child.kill(signal) }; + }, +})); import { buildHermesArgs, @@ -16,9 +26,20 @@ import { resolveCliSettings, } from "../cli-spawn.js"; import type { HermesCliSettings } from "../cli-spawn.js"; +import { __resetHermesLaunchCacheForTests } from "../windows-binary-launch.js"; // ── Helpers ──────────────────────────────────────────────────────────────── +function flushAsync(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +function setPlatform(platform: NodeJS.Platform): () => void { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + return () => Object.defineProperty(process, "platform", descriptor!); +} + function defaultSettings(overrides: Partial = {}): HermesCliSettings { return { binaryPath: "hermes", @@ -248,6 +269,88 @@ describe("parseHermesOutput", () => { describe("invokeHermesCli", () => { beforeEach(() => { vi.clearAllMocks(); + __resetHermesLaunchCacheForTests(); + }); + + it("launches a Windows .cmd prompt turn through cmd.exe with escaped prompt data", async () => { + const restorePlatform = setPlatform("win32"); + try { + const whereChild = makeFakeChild(); + const turnChild = makeFakeChild(); + mockSpawn.mockReturnValueOnce(whereChild.child).mockReturnValueOnce(turnChild.child); + + const prompt = 'hi" & calc.exe'; + const promise = invokeHermesCli(prompt, defaultSettings()); + await flushAsync(); + whereChild.emitStdout("C:\\shims\\hermes.cmd\r\n"); + whereChild.emitClose(0); + await flushAsync(); + + expect(mockSuperviseSpawn).toHaveBeenCalledOnce(); + const [command, args, options] = mockSpawn.mock.calls[1]!; + expect(command).toBe("cmd.exe"); + expect(args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(args[3]).toContain("C:\\shims\\hermes.cmd"); + expect(args[3]).toContain('hi\\" ^& calc.exe'); + expect(options.windowsVerbatimArguments).toBe(true); + + turnChild.emitStdout(fakeHermesOutput("ok")); + turnChild.emitClose(0); + await expect(promise).resolves.toMatchObject({ body: "ok" }); + } finally { + restorePlatform(); + } + }); + + it("reuses Windows path lookup without replaying a prior prompt command line", async () => { + const restorePlatform = setPlatform("win32"); + try { + const whereChild = makeFakeChild(); + const firstTurn = makeFakeChild(); + const secondTurn = makeFakeChild(); + mockSpawn + .mockReturnValueOnce(whereChild.child) + .mockReturnValueOnce(firstTurn.child) + .mockReturnValueOnce(secondTurn.child); + + const first = invokeHermesCli("first prompt", defaultSettings()); + await flushAsync(); + whereChild.emitStdout("C:\\shims\\hermes.cmd\n"); + whereChild.emitClose(0); + await flushAsync(); + firstTurn.emitStdout(fakeHermesOutput("first")); + firstTurn.emitClose(0); + await first; + + const second = invokeHermesCli("second prompt", defaultSettings()); + await flushAsync(); + secondTurn.emitStdout(fakeHermesOutput("second")); + secondTurn.emitClose(0); + await second; + + expect(mockSpawn).toHaveBeenCalledTimes(3); + expect(mockSpawn.mock.calls[1]![1][3]).toContain("first prompt"); + expect(mockSpawn.mock.calls[2]![1][3]).toContain("second prompt"); + expect(mockSpawn.mock.calls[2]![1][3]).not.toContain("first prompt"); + } finally { + restorePlatform(); + } + }); + + it("keeps plugin timeout ownership above the supervisor backstop", async () => { + vi.useFakeTimers(); + try { + const { child, kill } = makeFakeChild(); + mockSpawn.mockReturnValue(child); + const promise = invokeHermesCli("hi", defaultSettings({ cliTimeoutMs: 10 })); + const timedOut = expect(promise).rejects.toThrow("hermes: process timed out after 10ms"); + await vi.advanceTimersByTimeAsync(10); + await timedOut; + expect(kill).toHaveBeenCalledWith("SIGKILL"); + expect(mockSuperviseSpawn.mock.calls[0]![2].maxLifetimeMs).toBeGreaterThan(10); + } finally { + vi.useRealTimers(); + } }); it("first call passes correct args and no --resume", async () => { @@ -256,6 +359,7 @@ describe("invokeHermesCli", () => { const PROMPT = "what is typescript?"; const promise = invokeHermesCli(PROMPT, defaultSettings()); + await flushAsync(); emitStdout(fakeHermesOutput("TypeScript is a language.")); emitClose(0); @@ -275,6 +379,7 @@ describe("invokeHermesCli", () => { mockSpawn.mockReturnValue(child); const promise = invokeHermesCli("hello again", defaultSettings(), "20260427_120000_abcd12"); + await flushAsync(); emitStdout(fakeHermesOutput("Hi there!")); emitClose(0); @@ -291,6 +396,7 @@ describe("invokeHermesCli", () => { mockSpawn.mockReturnValue(child); const promise = invokeHermesCli("hi", defaultSettings()); + await flushAsync(); emitStdout("Hello!\nsession_id: 20260427_120000_abcd12\n"); emitClose(0); @@ -303,6 +409,7 @@ describe("invokeHermesCli", () => { mockSpawn.mockReturnValue(child); const promise = invokeHermesCli("hi", defaultSettings()); + await flushAsync(); emitStdout("partial output"); emitStderr("fatal error from hermes"); emitClose(1); @@ -315,6 +422,7 @@ describe("invokeHermesCli", () => { mockSpawn.mockReturnValue(child); const promise = invokeHermesCli("hi", defaultSettings()); + await flushAsync(); emitStdout("Some output without session id line"); emitClose(0); @@ -333,6 +441,7 @@ describe("invokeHermesCli", () => { }); const promise = invokeHermesCli("test", settings); + await flushAsync(); emitStdout(fakeHermesOutput("ok")); emitClose(0); @@ -352,6 +461,7 @@ describe("invokeHermesCli", () => { mockSpawn.mockReturnValue(child); const promise = invokeHermesCli("hi", defaultSettings()); + await flushAsync(); const err = Object.assign(new Error("not found"), { code: "ENOENT" }); emitError(err); @@ -364,6 +474,7 @@ describe("invokeHermesCli", () => { const ac = new AbortController(); const promise = invokeHermesCli("hi", defaultSettings(), undefined, ac.signal); + await flushAsync(); // Abort before any output arrives. ac.abort(); @@ -379,6 +490,27 @@ describe("invokeHermesCli", () => { describe("listHermesProfiles", () => { beforeEach(() => { vi.clearAllMocks(); + __resetHermesLaunchCacheForTests(); + }); + + it("launches a Windows .cmd profile shim through cmd.exe", async () => { + const restorePlatform = setPlatform("win32"); + try { + const whereChild = makeFakeChild(); + const profileChild = makeFakeChild(); + mockSpawn.mockReturnValueOnce(whereChild.child).mockReturnValueOnce(profileChild.child); + const promise = listHermesProfiles(); + await flushAsync(); + whereChild.emitStdout("C:\\shims\\hermes.cmd\n"); + whereChild.emitClose(0); + await flushAsync(); + expect(mockSpawn.mock.calls[1]![0]).toBe("cmd.exe"); + expect(mockSpawn.mock.calls[1]![1].slice(0, 3)).toEqual(["/d", "/s", "/c"]); + profileChild.emitClose(0); + await expect(promise).resolves.toEqual([]); + } finally { + restorePlatform(); + } }); const SAMPLE_OUTPUT = [ @@ -392,6 +524,7 @@ describe("listHermesProfiles", () => { mockSpawn.mockReturnValue(child); const promise = listHermesProfiles(); + await flushAsync(); emitStdout(SAMPLE_OUTPUT); emitClose(0); @@ -418,6 +551,7 @@ describe("listHermesProfiles", () => { ].join("\n") + "\n"; const promise = listHermesProfiles(); + await flushAsync(); emitStdout(multiOutput); emitClose(0); @@ -436,6 +570,7 @@ describe("listHermesProfiles", () => { mockSpawn.mockReturnValue(child); const promise = listHermesProfiles({ binaryPath: "/no/such/hermes" }); + await flushAsync(); const err = Object.assign(new Error("not found"), { code: "ENOENT" }); emitError(err); @@ -447,6 +582,7 @@ describe("listHermesProfiles", () => { mockSpawn.mockReturnValue(child); const promise = listHermesProfiles(); + await flushAsync(); emitStdout(""); emitStderr("unknown subcommand"); emitClose(2); @@ -459,6 +595,7 @@ describe("listHermesProfiles", () => { mockSpawn.mockReturnValue(child); const promise = listHermesProfiles({ binaryPath: "/custom/hermes" }); + await flushAsync(); emitStdout(SAMPLE_OUTPUT); emitClose(0); diff --git a/plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts b/plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts index e46b129c16..0d2d0dd39f 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/__tests__/probe.test.ts @@ -9,10 +9,17 @@ const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); vi.mock("node:child_process", () => ({ spawn: mockSpawn })); import { probeHermesBinary } from "../probe.js"; +import { __resetHermesLaunchCacheForTests } from "../windows-binary-launch.js"; // ── Helpers ──────────────────────────────────────────────────────────────── /** Yields to the microtask and I/O queue so awaited code can continue. */ +function setPlatform(platform: NodeJS.Platform): () => void { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + return () => Object.defineProperty(process, "platform", descriptor!); +} + function flushAsync(): Promise { return new Promise((resolve) => setImmediate(resolve)); } @@ -45,6 +52,56 @@ function makeFakeChild(): { describe("probeHermesBinary", () => { beforeEach(() => { vi.clearAllMocks(); + __resetHermesLaunchCacheForTests(); + }); + + it("launches a Windows .cmd probe through cmd.exe and reports the Hermes shim", async () => { + const restorePlatform = setPlatform("win32"); + try { + const whereChild = makeFakeChild(); + const versionChild = makeFakeChild(); + mockSpawn.mockReturnValueOnce(whereChild.child).mockReturnValueOnce(versionChild.child); + const promise = probeHermesBinary({ timeoutMs: 500 }); + await flushAsync(); + whereChild.emitStdout("C:\\shims\\hermes.cmd\r\n"); + whereChild.emitClose(0); + await flushAsync(); + + const [command, args, options] = mockSpawn.mock.calls[1]!; + expect(command).toBe("cmd.exe"); + expect(args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(args[3]).toContain("C:\\shims\\hermes.cmd"); + expect(options.windowsVerbatimArguments).toBe(true); + versionChild.emitStdout("Hermes Agent v1.2.3\n"); + versionChild.emitClose(0); + + await expect(promise).resolves.toMatchObject({ + available: true, + binaryPath: "C:\\shims\\hermes.cmd", + }); + } finally { + restorePlatform(); + } + }); + + it("launches a Windows .exe probe directly and reports that executable", async () => { + const restorePlatform = setPlatform("win32"); + try { + const whereChild = makeFakeChild(); + const versionChild = makeFakeChild(); + mockSpawn.mockReturnValueOnce(whereChild.child).mockReturnValueOnce(versionChild.child); + const promise = probeHermesBinary({ timeoutMs: 500 }); + await flushAsync(); + whereChild.emitStdout("C:\\shims\\hermes.exe\n"); + whereChild.emitClose(0); + await flushAsync(); + expect(mockSpawn.mock.calls[1]![0]).toBe("C:\\shims\\hermes.exe"); + expect(mockSpawn.mock.calls[1]![1]).toEqual(["--version"]); + versionChild.emitClose(0); + await expect(promise).resolves.toMatchObject({ binaryPath: "C:\\shims\\hermes.exe" }); + } finally { + restorePlatform(); + } }); it("returns available: false with not-found reason on ENOENT", async () => { @@ -58,6 +115,7 @@ describe("probeHermesBinary", () => { .mockReturnValueOnce(versionChild.child); // hermes --version const promise = probeHermesBinary({ timeoutMs: 500 }); + await flushAsync(); // Settle the `which` call — which causes tryResolveBinaryPath to resolve. whichChild.emitClose(1); @@ -85,6 +143,7 @@ describe("probeHermesBinary", () => { .mockReturnValueOnce(versionChild.child); const promise = probeHermesBinary({ binaryPath: "hermes", timeoutMs: 500 }); + await flushAsync(); whichChild.emitStdout("/usr/local/bin/hermes\n"); whichChild.emitClose(0); @@ -112,6 +171,7 @@ describe("probeHermesBinary", () => { .mockReturnValueOnce(versionChild.child); const promise = probeHermesBinary({ timeoutMs: 500 }); + await flushAsync(); whichChild.emitClose(1); @@ -135,6 +195,7 @@ describe("probeHermesBinary", () => { .mockReturnValueOnce(versionChild.child); const promise = probeHermesBinary({ binaryPath: "/opt/bin/hermes", timeoutMs: 500 }); + await flushAsync(); whichChild.emitClose(1); diff --git a/plugins/fusion-plugin-hermes-runtime/src/__tests__/windows-binary-launch.test.ts b/plugins/fusion-plugin-hermes-runtime/src/__tests__/windows-binary-launch.test.ts new file mode 100644 index 0000000000..9918c5f9af --- /dev/null +++ b/plugins/fusion-plugin-hermes-runtime/src/__tests__/windows-binary-launch.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + __resetHermesLaunchCacheForTests, + escapeWindowsShellArgument, + escapeWindowsShellCommand, + resolveHermesBinaryPath, + resolveHermesLaunch, +} from "../windows-binary-launch.js"; + +const windows = { platform: "win32" as const, env: { PATH: "C:\\shims", PATHEXT: ".COM;.EXE;.BAT;.CMD" } }; + +afterEach(() => __resetHermesLaunchCacheForTests()); + +describe("Windows Hermes launch resolution", () => { + it("keeps POSIX launches direct and does not invoke where", async () => { + let calls = 0; + await expect(resolveHermesLaunch("hermes", ["chat"], { + platform: "linux", runWhere: async () => { calls += 1; return "C:\\shims\\hermes.cmd"; }, + })).resolves.toEqual({ command: "hermes", args: ["chat"] }); + expect(calls).toBe(0); + }); + + it("selects PATHEXT-preferred candidates in the first Windows directory on a POSIX host", async () => { + const launch = await resolveHermesLaunch("hermes", ["chat", "hi"], { + ...windows, + runWhere: async () => "C:\\Shims\\hermes.CMD\r\nc:\\shims\\hermes.EXE\r\nC:\\later\\hermes.COM\r\n", + }); + expect(launch).toMatchObject({ command: "c:\\shims\\hermes.EXE", args: ["chat", "hi"], resolvedBinaryPath: "c:\\shims\\hermes.EXE" }); + }); + + it("wraps cmd shims in a hardened cmd.exe payload", async () => { + const launch = await resolveHermesLaunch("hermes.cmd", ["chat", "hi\" & calc.exe", ""], { + ...windows, + env: { ...windows.env, ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + runWhere: async () => "C:\\Users\\A User\\hermes.cmd", + }); + expect(launch.command).toBe("C:\\Windows\\System32\\cmd.exe"); + expect(launch.args.slice(0, 3)).toEqual(["/d", "/s", "/c"]); + expect(launch.windowsVerbatimArguments).toBe(true); + expect(launch.resolvedBinaryPath).toBe("C:\\Users\\A User\\hermes.cmd"); + expect(launch.args[3]).toContain("^&"); + }); + + it("short-circuits Windows paths and preserves the Hermes resolved path", async () => { + for (const binary of ["C:\\dir\\hermes.cmd", "\\\\server\\share\\hermes.cmd", "C:hermes", "C:/dir/hermes.exe"]) { + const launch = await resolveHermesLaunch(binary, ["--version"], windows); + expect(launch.resolvedBinaryPath).toBe(binary); + } + }); + + it("escapes quote, trailing slash, and cmd metacharacter data", () => { + expect(escapeWindowsShellCommand("C:\\A User\\hermes.cmd")).toContain("^ "); + expect(escapeWindowsShellArgument("hi\" & | < > ^ ( ) % !\\")).toMatch(/^".*"$/); + expect(escapeWindowsShellArgument("")).toBe('""'); + }); + + it("does not cache injected where runners", async () => { + let calls = 0; + const deps = { ...windows, runWhere: async () => { calls += 1; return "C:\\shims\\hermes.exe"; } }; + await resolveHermesBinaryPath("hermes", deps); + await resolveHermesBinaryPath("hermes", deps); + expect(calls).toBe(2); + }); +}); diff --git a/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts index 20be6950c9..7e83eed6db 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts @@ -11,38 +11,11 @@ * one chunk once the process exits. */ -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import os from "node:os"; -import path, { sep as PATH_SEP } from "node:path"; - -/** - * On Windows, `spawn("hermes", ...)` won't find `hermes.cmd`/`.bat` shims — - * Node doesn't honor PATHEXT. We resolve via `where` (which does) and spawn - * the absolute path instead. No-op on POSIX. Cached per process. - */ -const resolvedBinaryCache = new Map(); - -function resolveBinaryForSpawn(binary: string): string { - if (process.platform !== "win32") return binary; - if (binary.includes(PATH_SEP) || binary.includes("/") || /\.[a-z]{2,4}$/i.test(binary)) { - return binary; - } - const cached = resolvedBinaryCache.get(binary); - if (cached) return cached; - try { - const result = spawnSync("where", [binary], { encoding: "utf-8" }); - if (result.status === 0) { - const first = (result.stdout ?? "").trim().split(/\r?\n/)[0]; - if (first?.length) { - resolvedBinaryCache.set(binary, first); - return first; - } - } - } catch { - // fall through - } - return binary; -} +import path from "node:path"; +import { superviseSpawn } from "@fusion/core"; +import { resolveHermesLaunch } from "./windows-binary-launch.js"; /** ANSI escape code stripping regex. */ // eslint-disable-next-line no-control-regex -- ANSI escapes are control chars by definition @@ -161,15 +134,19 @@ export async function listHermesProfiles(opts?: { binaryPath?: string; timeoutMs?: number; }): Promise { - const binary = resolveBinaryForSpawn(opts?.binaryPath ?? "hermes"); + const binary = opts?.binaryPath ?? "hermes"; const timeoutMs = opts?.timeoutMs ?? 5_000; + const spawnEnv = { ...process.env }; + const launch = await resolveHermesLaunch(binary, ["profile", "list"], { env: spawnEnv }); return new Promise((resolve, reject) => { let settled = false; - const child = spawn(binary, ["profile", "list"], { + // Profile listing is short and fully awaited; only prompt turns need parent-death supervision. + const child = spawn(launch.command, launch.args, { stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, + env: spawnEnv, + windowsVerbatimArguments: launch.windowsVerbatimArguments, }); const timer = setTimeout(() => { @@ -374,26 +351,33 @@ export async function invokeHermesCli( signal?: AbortSignal, ): Promise { const args = buildHermesArgs(prompt, settings, resumeSessionId); - const binary = resolveBinaryForSpawn(settings.binaryPath); + const spawnEnv: NodeJS.ProcessEnv = { ...process.env, PYTHONUNBUFFERED: "1" }; + if (settings.profile) { + spawnEnv.HERMES_HOME = hermesProfileHome(settings.profile); + } + const launch = await resolveHermesLaunch(settings.binaryPath, args, { env: spawnEnv }); return new Promise((resolve, reject) => { let settled = false; - - const spawnEnv: NodeJS.ProcessEnv = { ...process.env, PYTHONUNBUFFERED: "1" }; - if (settings.profile) { - spawnEnv.HERMES_HOME = hermesProfileHome(settings.profile); - } - - const child = spawn(binary, args, { + /* + FNXC:HermesCli 2026-08-15-15:46: + The plugin owns its timeout message, so the supervisor lifetime is only a shutdown backstop. + Setting it equal to the CLI timeout would let its close event hide the operator-visible timeout error. + */ + const supervised = superviseSpawn(launch.command, launch.args, { stdio: ["ignore", "pipe", "pipe"], env: spawnEnv, + windowsVerbatimArguments: launch.windowsVerbatimArguments, + maxLifetimeMs: settings.cliTimeoutMs + 2_100, + killGraceMs: 2_000, }); + const child = supervised.child; const hardKillTimer = setTimeout(() => { if (settled) return; settled = true; try { - child.kill("SIGKILL"); + supervised.kill("SIGKILL"); } catch { // already gone } @@ -406,7 +390,7 @@ export async function invokeHermesCli( settled = true; clearTimeout(hardKillTimer); try { - child.kill("SIGTERM"); + supervised.kill("SIGTERM"); } catch { // already gone } diff --git a/plugins/fusion-plugin-hermes-runtime/src/index.ts b/plugins/fusion-plugin-hermes-runtime/src/index.ts index f3dca7717c..d2ccc1998d 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/index.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/index.ts @@ -131,6 +131,14 @@ export { shouldInstallComputerUseSkill, } from "./fusion-skill-install.js"; export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js"; +export { + __resetHermesLaunchCacheForTests, + escapeWindowsShellArgument, + escapeWindowsShellCommand, + resolveHermesBinaryPath, + resolveHermesLaunch, +} from "./windows-binary-launch.js"; +export type { HermesLaunchSpec } from "./windows-binary-launch.js"; // Probe re-export for the dashboard's runtime-provider-probes façade. export { probeHermesBinary } from "./probe.js"; diff --git a/plugins/fusion-plugin-hermes-runtime/src/probe.ts b/plugins/fusion-plugin-hermes-runtime/src/probe.ts index edb6ef6cfa..42c723e164 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/probe.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/probe.ts @@ -6,6 +6,7 @@ */ import { spawn } from "node:child_process"; +import { resolveHermesLaunch } from "./windows-binary-launch.js"; /** Default probe timeout in milliseconds. */ const DEFAULT_PROBE_TIMEOUT_MS = 2000; @@ -46,7 +47,9 @@ export async function probeHermesBinary(opts?: { : "hermes"; const timeoutMs = opts?.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; - const resolvedPath = await tryResolveBinaryPath(binary); + const launch = await resolveHermesLaunch(binary, ["--version"], { env: process.env }); + // POSIX launch resolution is intentionally a no-op; retain which for the documented reporting path. + const resolvedPath = launch.resolvedBinaryPath ?? await tryResolvePosixBinaryPath(binary); return new Promise((resolvePromise) => { const finish = (result: Omit): void => { @@ -55,8 +58,9 @@ export async function probeHermesBinary(opts?: { let settled = false; - const child = spawn(resolvedPath ?? binary, ["--version"], { + const child = spawn(launch.command, launch.args, { stdio: ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: launch.windowsVerbatimArguments, }); const timer = setTimeout(() => { @@ -121,14 +125,11 @@ export async function probeHermesBinary(opts?: { }); } -/** - * Best-effort path resolution via `which` (POSIX) or `where` (Windows). - * Returns undefined on failure — the spawn above is the actual authority. - */ -async function tryResolveBinaryPath(binary: string): Promise { +/** Best-effort POSIX reporting lookup; Windows resolution belongs to windows-binary-launch. */ +async function tryResolvePosixBinaryPath(binary: string): Promise { + if (process.platform === "win32") return undefined; return new Promise((resolvePromise) => { - const which = process.platform === "win32" ? "where" : "which"; - const child = spawn(which, [binary], { stdio: ["ignore", "pipe", "ignore"] }); + const child = spawn("which", [binary], { stdio: ["ignore", "pipe", "ignore"] }); let out = ""; child.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString("utf-8"); diff --git a/plugins/fusion-plugin-hermes-runtime/src/windows-binary-launch.ts b/plugins/fusion-plugin-hermes-runtime/src/windows-binary-launch.ts new file mode 100644 index 0000000000..64405dc66f --- /dev/null +++ b/plugins/fusion-plugin-hermes-runtime/src/windows-binary-launch.ts @@ -0,0 +1,184 @@ +import { spawn } from "node:child_process"; +import { win32 } from "node:path"; + +export interface HermesLaunchSpec { + command: string; + args: string[]; + windowsVerbatimArguments?: boolean; + /** The selected Hermes executable, never the cmd.exe wrapper. */ + resolvedBinaryPath?: string; +} + +export interface HermesLaunchDependencies { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + runWhere?: (binary: string, env: NodeJS.ProcessEnv) => Promise; +} + +const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD"; +export const NEGATIVE_RESULT_TTL_MS = 30_000; +export const MAX_CACHE_ENTRIES = 32; + +interface CachedResolution { + promise: Promise; + createdAt: number; +} + +const resolutionCache = new Map(); + +/* +FNXC:HermesCli 2026-08-15-15:46: +Fusion resolves but never installs, downloads, or pins the operator-installed Hermes CLI. Node refuses direct `.cmd`/`.bat` spawns without a shell, so shims are explicitly launched through cmd.exe with escaped data rather than a blanket shell option. + +FNXC:HermesCli 2026-08-15-15:46: +Windows candidate paths must use path.win32 even on POSIX test hosts: host path parsing turns C:\\shims\\hermes.cmd into `.` and defeats first-directory selection. Windows lookup chooses the first PATH directory then PATHEXT precedence inside it, matching where-style discovery. + +FNXC:HermesCli 2026-08-15-15:46: +Only prompt-free binary lookup is cached. Launch specs contain prompt argv and must be rebuilt per turn; misses expire so a newly installed Hermes is discovered without restart. +*/ +function effectivePathExt(env: NodeJS.ProcessEnv): string[] { + const raw = env.PATHEXT?.trim() || DEFAULT_PATHEXT; + return raw.split(";") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => (entry.startsWith(".") ? entry : `.${entry}`).toLowerCase()); +} + +function isWindowsPath(value: string): boolean { + return value.includes("\\") || value.includes("/") || /^[A-Za-z]:/.test(value) || /^\\\\/.test(value); +} + +function cacheKey(binary: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string { + return `${platform}|${binary.toLowerCase()}|${env.PATH ?? ""}|${env.PATHEXT ?? ""}`; +} + +function evictOldestIfNeeded(): void { + while (resolutionCache.size >= MAX_CACHE_ENTRIES) { + const oldest = resolutionCache.keys().next().value as string | undefined; + if (!oldest) return; + resolutionCache.delete(oldest); + } +} + +async function defaultRunWhere(binary: string, env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve) => { + let child; + try { + child = spawn("where", [binary], { + env, + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + resolve(undefined); + return; + } + let stdout = ""; + child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); + child.once("error", () => resolve(undefined)); + child.once("close", (code) => resolve(code === 0 && stdout.trim() ? stdout : undefined)); + }); +} + +function chooseWhereCandidate(stdout: string, env: NodeJS.ProcessEnv): string | undefined { + const directories: Array<{ entries: string[] }> = []; + const byDirectory = new Map(); + for (const entry of stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)) { + const directory = win32.dirname(entry).replace(/[\\/]+$/, "").toLowerCase(); + const group = byDirectory.get(directory) ?? { entries: [] }; + if (!byDirectory.has(directory)) { + byDirectory.set(directory, group); + directories.push(group); + } + group.entries.push(entry); + } + const firstDirectory = directories[0]; + if (!firstDirectory) return undefined; + const pathExt = effectivePathExt(env); + return [...firstDirectory.entries].sort((left, right) => { + const leftIndex = pathExt.indexOf(win32.extname(left).toLowerCase()); + const rightIndex = pathExt.indexOf(win32.extname(right).toLowerCase()); + const normalizedLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex; + const normalizedRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex; + return normalizedLeft - normalizedRight; + })[0]; +} + +/** Resolve only a Windows bare binary name; this prompt-free result is cacheable. */ +export async function resolveHermesBinaryPath( + binary: string, + deps: HermesLaunchDependencies = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const env = deps.env ?? process.env; + if (platform !== "win32" || isWindowsPath(binary)) return platform === "win32" && isWindowsPath(binary) ? binary : undefined; + + const lookup = async (): Promise => { + const output = await (deps.runWhere ?? defaultRunWhere)(binary, env); + return output ? chooseWhereCandidate(output, env) : undefined; + }; + // Injected runners are deterministic test seams and must not share process cache state. + if (deps.runWhere) return lookup(); + + const key = cacheKey(binary, platform, env); + const cached = resolutionCache.get(key); + if (cached) { + const result = await cached.promise; + // Positive resolutions remain stable, while misses are retried after the bounded install-discovery window. + if (result !== undefined || Date.now() - cached.createdAt < NEGATIVE_RESULT_TTL_MS) return result; + resolutionCache.delete(key); + } + + evictOldestIfNeeded(); + const entry: CachedResolution = { promise: lookup(), createdAt: Date.now() }; + resolutionCache.set(key, entry); + entry.promise.catch(() => { + if (resolutionCache.get(key) === entry) resolutionCache.delete(key); + }); + return entry.promise; +} + +/** Escape cmd.exe metacharacters in a command path without adding outer quotes. */ +export function escapeWindowsShellCommand(command: string): string { + return command.replace(/([()%!^<>&|;,\s])/g, "^$1"); +} + +/** + * Quote one argv item for the cmd.exe /c payload. + * Quoting alone is insufficient because cmd.exe expands `%` and parses command + * separators after receiving the payload, so metacharacters are caret escaped too. + */ +export function escapeWindowsShellArgument(arg: string): string { + const quoted = arg + .replace(/(\\*)"/g, "$1$1\\\"") + .replace(/(\\*)$/g, "$1$1"); + return `"${quoted}"`.replace(/([()%!^<>&|;,])/g, "^$1"); +} + +/** Build a per-call launch specification. This deliberately never caches args. */ +export async function resolveHermesLaunch( + binary: string, + args: readonly string[], + deps: HermesLaunchDependencies = {}, +): Promise { + const platform = deps.platform ?? process.platform; + if (platform !== "win32") return { command: binary, args: [...args] }; + const env = deps.env ?? process.env; + const resolved = await resolveHermesBinaryPath(binary, { ...deps, platform, env }); + const executable = resolved ?? binary; + const extension = win32.extname(executable).toLowerCase(); + if (extension === ".cmd" || extension === ".bat") { + const commandLine = `"${escapeWindowsShellCommand(executable)} ${args.map(escapeWindowsShellArgument).join(" ")}"`; + return { + command: env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", commandLine], + windowsVerbatimArguments: true, + resolvedBinaryPath: resolved, + }; + } + return { command: executable, args: [...args], resolvedBinaryPath: resolved }; +} + +export function __resetHermesLaunchCacheForTests(): void { + resolutionCache.clear(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dae37b7a08..3cf3ee7801 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1103,6 +1103,9 @@ importers: plugins/fusion-plugin-hermes-runtime: dependencies: + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk