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>
185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
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();
|
|
}
|