fix: spawn dev services without a shell on Windows (#1859)

On Windows, spawnService ran uvx through cmd.exe (shell: true), so the `<`
in the `agent-client-protocol<0.11` version constraint was parsed as input
redirection and agent-server exited immediately with "The system cannot find
the file specified."

Resolve the command to its absolute path with where.exe and spawn it directly,
with no shell, so argument metacharacters stay literal. npm is unaffected: it is
already wrapped in cmd.exe by buildNpmScriptCommand before it reaches
spawnService.
This commit is contained in:
Vasco Schiavo
2026-07-20 11:00:13 +02:00
committed by GitHub
parent 70a678869a
commit 2d7a3985b3
4 changed files with 68 additions and 4 deletions
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
getProcessTreeSpawnOptions,
isProcessRunning,
resolveWindowsCommand,
} from "../../scripts/dev-process-utils.mjs";
describe("dev process utils", () => {
@@ -30,3 +31,31 @@ describe("dev process utils", () => {
});
});
});
describe("resolveWindowsCommand", () => {
const lookup = (cmd: string) => `C:\\Users\\me\\.local\\bin\\${cmd}.exe`;
it("returns the command unchanged on non-Windows platforms", () => {
expect(resolveWindowsCommand("uvx", "linux", lookup)).toBe("uvx");
expect(resolveWindowsCommand("uvx", "darwin", lookup)).toBe("uvx");
});
it("resolves a bare command to its absolute path on Windows so it can spawn without a shell", () => {
// Spawning uvx directly (not via cmd.exe) keeps arguments such as
// `--with agent-client-protocol<0.11` literal, instead of the `<` being
// parsed as input redirection and failing with "The system cannot find the
// file specified."
expect(resolveWindowsCommand("uvx", "win32", lookup)).toBe(
"C:\\Users\\me\\.local\\bin\\uvx.exe",
);
});
it("leaves an already-resolved path untouched on Windows", () => {
const absolute = "C:\\Windows\\System32\\cmd.exe";
expect(resolveWindowsCommand(absolute, "win32", lookup)).toBe(absolute);
});
it("falls back to the original command when the lookup fails on Windows", () => {
expect(resolveWindowsCommand("uvx", "win32", () => null)).toBe("uvx");
});
});
+35
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import process from "node:process";
/**
@@ -33,6 +34,40 @@ export function getProcessTreeSpawnOptions(options = {}) {
};
}
/**
* Resolve a service command to a directly spawnable target on Windows.
*
* Services spawn without a shell so argument values reach the child verbatim.
* Spawning `uvx` via cmd.exe instead makes it parse the args: a constraint like
* `agent-client-protocol<0.11` is read as `<` input redirection and the spawn
* dies with "The system cannot find the file specified." Resolving to an
* absolute path lets callers spawn it shell-free.
*
* Returns `command` unchanged off Windows, when already a path, or if the lookup
* fails.
*/
export function resolveWindowsCommand(
command,
platform = process.platform,
lookup = whereCommandLookup,
) {
if (platform !== "win32") {
return command;
}
if (command.includes("/") || command.includes("\\")) {
return command;
}
return lookup(command) || command;
}
function whereCommandLookup(command) {
const result = spawnSync("where.exe", [command], { encoding: "utf8" });
if (result.status !== 0 || !result.stdout) {
return null;
}
return result.stdout.split(/\r?\n/).find(Boolean)?.trim() || null;
}
/**
* Signal the whole spawned service tree when possible.
*
+2 -2
View File
@@ -54,6 +54,7 @@ import {
import {
getProcessTreeSpawnOptions,
isProcessRunning,
resolveWindowsCommand,
signalProcessTree,
} from "./dev-process-utils.mjs";
import {
@@ -215,13 +216,12 @@ let shuttingDown = false;
function spawnService(name, command, args, options = {}) {
const proc = spawn(
command,
resolveWindowsCommand(command),
args,
getProcessTreeSpawnOptions({
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...options.env },
cwd: options.cwd,
shell: process.platform === "win32",
}),
);
+2 -2
View File
@@ -68,6 +68,7 @@ import {
createShutdownHookRegistry,
getProcessTreeSpawnOptions,
isProcessRunning,
resolveWindowsCommand,
signalProcessTree,
} from "./dev-process-utils.mjs";
import { fileLog, stripAnsi } from "./logger.mjs";
@@ -551,13 +552,12 @@ function registerShutdownHook(hook) {
function spawnService(name, command, args, options = {}) {
const proc = spawn(
command,
resolveWindowsCommand(command),
args,
getProcessTreeSpawnOptions({
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...options.env },
cwd: options.cwd,
shell: process.platform === "win32",
}),
);