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:
7
.changeset/fn-9099-hermes-windows-launch.md
Normal file
7
.changeset/fn-9099-hermes-windows-launch.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test": "vitest run --silent=passed-only --reporter=dot"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user