From 99200eabbaa14fbd2910cf11a0d6e4ae7fe3efe6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Apr 2026 09:42:12 +0700 Subject: [PATCH] fix(windows): quote shell args with spaces --- .../agent/provider-launch-config.test.ts | 37 +++++++++++++++++++ .../server/agent/provider-launch-config.ts | 12 ++++++ .../server/agent/providers/claude-agent.ts | 9 ++++- .../agent/providers/codex-app-server-agent.ts | 17 ++++++--- .../server/agent/providers/opencode-agent.ts | 5 ++- packages/server/src/server/exports.ts | 1 + 6 files changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 962313963..a2b674558 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { findExecutable, + quoteWindowsArgument, quoteWindowsCommand, resolveProviderCommandPrefix, applyProviderEnv, @@ -255,3 +256,39 @@ describe("quoteWindowsCommand", () => { expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); }); }); + +describe("quoteWindowsArgument", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows argument with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("does not double-quote an already-quoted argument", () => { + setPlatform("win32"); + expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("returns the argument unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("--version")).toBe("--version"); + }); + + test("returns the argument unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index ba5260082..b20ad8a86 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -286,6 +286,18 @@ export function quoteWindowsCommand(command: string): string { return `"${command}"`; } +/** + * `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`. + * Any argument containing spaces must be quoted or it will be split before the + * child process sees it. + */ +export function quoteWindowsArgument(argument: string): string { + if (process.platform !== "win32") return argument; + if (!argument.includes(" ")) return argument; + if (argument.startsWith('"') && argument.endsWith('"')) return argument; + return `"${argument}"`; +} + export function isCommandAvailable(command: string): boolean { return findExecutable(command) !== null; } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 00352f65d..b654ad15f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -70,6 +70,7 @@ import type { import { applyProviderEnv, findExecutable, + quoteWindowsArgument, quoteWindowsCommand, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; @@ -214,7 +215,10 @@ function applyRuntimeSettingsToClaudeOptions( const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun"; const command = isDefaultRuntime ? process.execPath : resolved.command; - const child = spawn(quoteWindowsCommand(command), resolved.args, { + const child = spawn( + quoteWindowsCommand(command), + resolved.args.map((argument) => quoteWindowsArgument(argument)), + { cwd: spawnOptions.cwd, env: { ...applyProviderEnv(spawnOptions.env, runtimeSettings), @@ -223,7 +227,8 @@ function applyRuntimeSettingsToClaudeOptions( shell: process.platform === "win32", signal: spawnOptions.signal, stdio: ["pipe", "pipe", "pipe"], - }); + }, + ); if (typeof options.stderr === "function") { child.stderr?.on("data", (chunk: Buffer | string) => { options.stderr?.(chunk.toString()); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index dd96e74e0..c0052a14f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -42,6 +42,7 @@ import { import { applyProviderEnv, findExecutable, + quoteWindowsArgument, quoteWindowsCommand, resolveProviderCommandPrefix, type ProviderRuntimeSettings, @@ -3420,12 +3421,16 @@ export class CodexAppServerAgentClient implements AgentClient { }, "Spawning Codex app server", ); - return spawn(quoteWindowsCommand(launchPrefix.command), [...launchPrefix.args, "app-server"], { - detached: process.platform !== "win32", - shell: process.platform === "win32", - stdio: ["pipe", "pipe", "pipe"], - env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), - }); + return spawn( + quoteWindowsCommand(launchPrefix.command), + [...launchPrefix.args, "app-server"].map((argument) => quoteWindowsArgument(argument)), + { + detached: process.platform !== "win32", + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), + }, + ); } async createSession( diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 8764b0df4..7ac1811dd 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -32,6 +32,7 @@ import type { import { applyProviderEnv, findExecutable, + quoteWindowsArgument, quoteWindowsCommand, resolveProviderCommandPrefix, type ProviderRuntimeSettings, @@ -335,7 +336,9 @@ export class OpenCodeServerManager { return new Promise((resolve, reject) => { this.server = spawn( quoteWindowsCommand(launchPrefix.command), - [...launchPrefix.args, "serve", "--port", String(this.port)], + [...launchPrefix.args, "serve", "--port", String(this.port)].map((argument) => + quoteWindowsArgument(argument), + ), { shell: process.platform === "win32", stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 3a4a110ea..ae059eabd 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -33,6 +33,7 @@ export { export { findExecutable, applyProviderEnv, + quoteWindowsArgument, quoteWindowsCommand, } from "./agent/provider-launch-config.js";