mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user