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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 12:38:24 -07:00
parent c0e568bf78
commit 743251a5d3
11 changed files with 508 additions and 54 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -23,6 +23,7 @@
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/plugin-sdk": "workspace:*"
},
"devDependencies": {

View File

@@ -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<string, unknown>) => {
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<void> {
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> = {}): 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);

View File

@@ -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<void> {
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);

View File

@@ -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);
});
});

View File

@@ -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<string, string>();
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<HermesProfileSummary[]> {
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<HermesProfileSummary[]>((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<HermesCliResult> {
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<HermesCliResult>((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
}

View File

@@ -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";

View File

@@ -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<HermesBinaryStatus>((resolvePromise) => {
const finish = (result: Omit<HermesBinaryStatus, "probeDurationMs">): 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<string | undefined> {
/** Best-effort POSIX reporting lookup; Windows resolution belongs to windows-binary-launch. */
async function tryResolvePosixBinaryPath(binary: string): Promise<string | undefined> {
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");

View File

@@ -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<string | undefined>;
}
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<string | undefined>;
createdAt: number;
}
const resolutionCache = new Map<string, CachedResolution>();
/*
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<string | undefined> {
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<string, { entries: string[] }>();
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<string | undefined> {
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<string | undefined> => {
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<HermesLaunchSpec> {
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();
}

3
pnpm-lock.yaml generated
View File

@@ -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