fix(windows): quote command paths with spaces when spawning with shell
shell: true passes commands to cmd.exe /d /s /c which strips outer quotes, causing paths like C:\Program Files\... to split at the space.
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
|
import {
|
||||||
|
getOrCreateServerId,
|
||||||
|
findExecutable,
|
||||||
|
quoteWindowsCommand,
|
||||||
|
applyProviderEnv,
|
||||||
|
} from "@getpaseo/server";
|
||||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||||
@@ -170,7 +175,7 @@ function checkProviderBinary(binary: string): { path: string | null; version: st
|
|||||||
}
|
}
|
||||||
const env = applyProviderEnv(process.env);
|
const env = applyProviderEnv(process.env);
|
||||||
try {
|
try {
|
||||||
const output = execFileSync(binaryPath, ["--version"], {
|
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findExecutable,
|
findExecutable,
|
||||||
|
quoteWindowsCommand,
|
||||||
resolveProviderCommandPrefix,
|
resolveProviderCommandPrefix,
|
||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
@@ -218,3 +219,39 @@ describe("findExecutable", () => {
|
|||||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
|
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("quoteWindowsCommand", () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
|
||||||
|
function setPlatform(value: string) {
|
||||||
|
Object.defineProperty(process, "platform", { value, writable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setPlatform(originalPlatform);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotes a Windows path with spaces", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||||
|
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not double-quote an already-quoted path", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||||
|
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the command unchanged when there are no spaces", () => {
|
||||||
|
setPlatform("win32");
|
||||||
|
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the command unchanged on non-Windows platforms", () => {
|
||||||
|
setPlatform("darwin");
|
||||||
|
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -273,6 +273,19 @@ export function findExecutable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When spawning with `shell: true` on Windows, the command is passed to
|
||||||
|
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
|
||||||
|
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
|
||||||
|
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
|
||||||
|
*/
|
||||||
|
export function quoteWindowsCommand(command: string): string {
|
||||||
|
if (process.platform !== "win32") return command;
|
||||||
|
if (!command.includes(" ")) return command;
|
||||||
|
if (command.startsWith('"') && command.endsWith('"')) return command;
|
||||||
|
return `"${command}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export function isCommandAvailable(command: string): boolean {
|
export function isCommandAvailable(command: string): boolean {
|
||||||
return findExecutable(command) !== null;
|
return findExecutable(command) !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
findExecutable,
|
findExecutable,
|
||||||
|
quoteWindowsCommand,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||||
@@ -213,7 +214,7 @@ function applyRuntimeSettingsToClaudeOptions(
|
|||||||
const isDefaultRuntime =
|
const isDefaultRuntime =
|
||||||
resolved.command === "node" || resolved.command === "bun";
|
resolved.command === "node" || resolved.command === "bun";
|
||||||
const command = isDefaultRuntime ? process.execPath : resolved.command;
|
const command = isDefaultRuntime ? process.execPath : resolved.command;
|
||||||
const child = spawn(command, resolved.args, {
|
const child = spawn(quoteWindowsCommand(command), resolved.args, {
|
||||||
cwd: spawnOptions.cwd,
|
cwd: spawnOptions.cwd,
|
||||||
env: {
|
env: {
|
||||||
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
findExecutable,
|
findExecutable,
|
||||||
|
quoteWindowsCommand,
|
||||||
resolveProviderCommandPrefix,
|
resolveProviderCommandPrefix,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
@@ -3419,7 +3420,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
},
|
},
|
||||||
"Spawning Codex app server",
|
"Spawning Codex app server",
|
||||||
);
|
);
|
||||||
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
return spawn(quoteWindowsCommand(launchPrefix.command), [...launchPrefix.args, "app-server"], {
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
findExecutable,
|
findExecutable,
|
||||||
|
quoteWindowsCommand,
|
||||||
resolveProviderCommandPrefix,
|
resolveProviderCommandPrefix,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
@@ -333,7 +334,7 @@ export class OpenCodeServerManager {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.server = spawn(
|
this.server = spawn(
|
||||||
launchPrefix.command,
|
quoteWindowsCommand(launchPrefix.command),
|
||||||
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
||||||
{
|
{
|
||||||
shell: process.platform === "win32",
|
shell: process.platform === "win32",
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export {
|
|||||||
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
|
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
|
||||||
|
|
||||||
// Provider binary resolution
|
// Provider binary resolution
|
||||||
export { findExecutable, applyProviderEnv } from "./agent/provider-launch-config.js";
|
export {
|
||||||
|
findExecutable,
|
||||||
|
applyProviderEnv,
|
||||||
|
quoteWindowsCommand,
|
||||||
|
} from "./agent/provider-launch-config.js";
|
||||||
|
|
||||||
// Agent SDK types for CLI commands
|
// Agent SDK types for CLI commands
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Reference in New Issue
Block a user