diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c586cfb..78d184b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Repo: https://github.com/openclaw/acpx - ACP/prompt blocks: preserve structured ACP prompt blocks instead of flattening them during prompt handling to support images and non-text. (#103) Thanks @vincentkoc. - Images/prompt validation: validate structured image prompt block MIME types and base64 payloads, emit human-readable CLI usage errors, and add an explicit non-CI live Cursor ACP smoke test path. Thanks @vincentkoc. +- Windows/process spawning: detect PATH-resolved batch wrappers such as `npx` on Windows and enable shell mode only for those commands. (#90) Thanks @lynnzc. ## 2026.3.10 (v0.1.16) diff --git a/src/client.ts b/src/client.ts index 22c41661..0eab2280 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess, type ChildProcessByStdio } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { Readable, Writable } from "node:stream"; import { @@ -288,6 +289,79 @@ function isCopilotAcpCommand(command: string, args: readonly string[]): boolean return basenameToken(command) === "copilot" && args.includes("--acp"); } +function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined { + const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key); + return matchedKey ? env[matchedKey] : undefined; +} + +function resolveWindowsCommand( + command: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const extensions = (readWindowsEnvValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .map((value) => value.trim().toLowerCase()) + .filter((value) => value.length > 0); + const commandExtension = path.extname(command); + const candidates = + commandExtension.length > 0 + ? [command] + : extensions.map((extension) => `${command}${extension}`); + const hasPath = command.includes("/") || command.includes("\\") || path.isAbsolute(command); + + if (hasPath) { + return candidates.find((candidate) => fs.existsSync(candidate)); + } + + const pathValue = readWindowsEnvValue(env, "PATH"); + if (!pathValue) { + return undefined; + } + + for (const directory of pathValue.split(";")) { + const trimmedDirectory = directory.trim(); + if (trimmedDirectory.length === 0) { + continue; + } + for (const candidate of candidates) { + const resolved = path.join(trimmedDirectory, candidate); + if (fs.existsSync(resolved)) { + return resolved; + } + } + } + + return undefined; +} + +function shouldUseWindowsBatchShell( + command: string, + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (platform !== "win32") { + return false; + } + const resolvedCommand = resolveWindowsCommand(command, env) ?? command; + const ext = path.extname(resolvedCommand).toLowerCase(); + return ext === ".cmd" || ext === ".bat"; +} + +export function buildSpawnCommandOptions( + command: string, + options: Parameters[2], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): Parameters[2] { + if (!shouldUseWindowsBatchShell(command, platform, env)) { + return options; + } + return { + ...options, + shell: true, + }; +} + function resolveGeminiAcpStartupTimeoutMs(): number { const raw = process.env.ACPX_GEMINI_ACP_STARTUP_TIMEOUT_MS; if (typeof raw === "string" && raw.trim().length > 0) { @@ -312,10 +386,14 @@ function resolveClaudeAcpSessionCreateTimeoutMs(): number { async function detectGeminiVersion(command: string): Promise { return await new Promise((resolve) => { - const child = spawn(command, ["--version"], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); + const child = spawn( + command, + ["--version"], + buildSpawnCommandOptions(command, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }), + ); let stdout = ""; let stderr = ""; @@ -363,10 +441,14 @@ async function readCommandOutput( timeoutMs: number, ): Promise { return await new Promise((resolve) => { - const child = spawn(command, [...args], { - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); + const child = spawn( + command, + [...args], + buildSpawnCommandOptions(command, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }), + ); let stdout = ""; let stderr = ""; @@ -741,7 +823,10 @@ export class AcpClient { const spawnedChild = spawn( command, args, - buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials), + buildSpawnCommandOptions( + command, + buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials), + ), ) as ChildProcessByStdio; try { diff --git a/test/spawn-options.test.ts b/test/spawn-options.test.ts index 3495748f..6b5bcec2 100644 --- a/test/spawn-options.test.ts +++ b/test/spawn-options.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; -import { buildAgentSpawnOptions } from "../src/client.js"; +import { buildAgentSpawnOptions, buildSpawnCommandOptions } from "../src/client.js"; import { buildQueueOwnerSpawnOptions } from "../src/session-runtime/queue-owner-process.js"; import { buildTerminalSpawnOptions } from "../src/terminal.js"; @@ -36,3 +39,65 @@ test("buildQueueOwnerSpawnOptions hides Windows console windows and passes paylo assert.equal(options.windowsHide, true); assert.equal(options.env.ACPX_QUEUE_OWNER_PAYLOAD, '{"sessionId":"queue-session"}'); }); + +test("buildSpawnCommandOptions enables shell for .cmd/.bat on Windows", () => { + const base = { + stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"], + windowsHide: true, + }; + + const cmdOptions = buildSpawnCommandOptions("C:\\Program Files\\nodejs\\npx.cmd", base, "win32"); + const batOptions = buildSpawnCommandOptions("C:\\tools\\agent.bat", base, "win32"); + + assert.equal(cmdOptions.shell, true); + assert.equal(batOptions.shell, true); + assert.deepEqual(cmdOptions.stdio, base.stdio); + assert.equal(cmdOptions.windowsHide, true); +}); + +test("buildSpawnCommandOptions enables shell for PATH-resolved .cmd wrappers on Windows", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-")); + const env = { + PATH: tempDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }; + const base = { + stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"], + windowsHide: true, + }; + + try { + await fs.writeFile(path.join(tempDir, "npx.cmd"), "@echo off\r\n"); + + const options = buildSpawnCommandOptions("npx", base, "win32", env); + assert.equal(options.shell, true); + assert.deepEqual(options.stdio, base.stdio); + assert.equal(options.windowsHide, true); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test("buildSpawnCommandOptions keeps shell disabled for non-batch commands", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "acpx-windows-spawn-")); + const env = { + PATH: tempDir, + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }; + const base = { + stdio: ["pipe", "pipe", "pipe"] as ["pipe", "pipe", "pipe"], + windowsHide: true, + }; + + try { + await fs.writeFile(path.join(tempDir, "node.exe"), ""); + + const linuxOptions = buildSpawnCommandOptions("/usr/bin/npx", base, "linux"); + const windowsExeOptions = buildSpawnCommandOptions("node", base, "win32", env); + + assert.equal(linuxOptions.shell, undefined); + assert.equal(windowsExeOptions.shell, undefined); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +});