From e985324d87fa53acb3f5549bfa153deceb4c3f49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 14:08:23 +0900 Subject: [PATCH] fix(acpx): preserve Windows Claude CLI paths --- .../acpx/src/runtime-internals/mcp-proxy.mjs | 134 +++++++++++++----- .../src/runtime-internals/mcp-proxy.test.ts | 49 +++++++ 2 files changed, 151 insertions(+), 32 deletions(-) diff --git a/extensions/acpx/src/runtime-internals/mcp-proxy.mjs b/extensions/acpx/src/runtime-internals/mcp-proxy.mjs index ac46837a73b..3717640b464 100644 --- a/extensions/acpx/src/runtime-internals/mcp-proxy.mjs +++ b/extensions/acpx/src/runtime-internals/mcp-proxy.mjs @@ -1,21 +1,46 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import path from "node:path"; import { createInterface } from "node:readline"; +import { pathToFileURL } from "node:url"; -function splitCommandLine(value) { +const WINDOWS_EXECUTABLE_PATH_RE = + /^(?(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?.*))?$/i; + +function splitCommandParts(value, platform = process.platform) { const parts = []; let current = ""; let quote = null; let escaping = false; - for (const ch of value) { + for (let index = 0; index < value.length; index += 1) { + const ch = value[index]; + const next = value[index + 1]; if (escaping) { current += ch; escaping = false; continue; } - if (ch === "\\" && quote !== "'") { + if (ch === "\\") { + if (quote === "'") { + current += ch; + continue; + } + if (platform === "win32") { + if (quote === '"') { + if (next === '"' || next === "\\") { + escaping = true; + continue; + } + current += ch; + continue; + } + if (!quote) { + current += ch; + continue; + } + } escaping = true; continue; } @@ -50,6 +75,37 @@ function splitCommandLine(value) { if (current.length > 0) { parts.push(current); } + if (parts.length === 0) { + return []; + } + return parts; +} + +function splitWindowsExecutableCommand(value, platform = process.platform) { + if (platform !== "win32") { + return null; + } + const trimmed = value.trim(); + if (!trimmed || trimmed.startsWith('"') || trimmed.startsWith("'")) { + return null; + } + const match = trimmed.match(WINDOWS_EXECUTABLE_PATH_RE); + if (!match?.groups?.command) { + return null; + } + const rest = match.groups.rest?.trim() ?? ""; + return { + command: match.groups.command, + args: rest ? splitCommandParts(rest, platform) : [], + }; +} + +export function splitCommandLine(value, platform = process.platform) { + const windowsCommand = splitWindowsExecutableCommand(value, platform); + if (windowsCommand) { + return windowsCommand; + } + const parts = splitCommandParts(value, platform); if (parts.length === 0) { throw new Error("Invalid agent command: empty command"); } @@ -116,36 +172,50 @@ function rewriteLine(line, mcpServers) { } } -const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2)); -const target = splitCommandLine(targetCommand); -const child = spawn(target.command, target.args, { - stdio: ["pipe", "pipe", "inherit"], - env: process.env, -}); - -if (!child.stdin || !child.stdout) { - throw new Error("Failed to create MCP proxy stdio pipes"); +function isMainModule() { + const mainPath = process.argv[1]; + if (!mainPath) { + return false; + } + return import.meta.url === pathToFileURL(path.resolve(mainPath)).href; } -const input = createInterface({ input: process.stdin }); -input.on("line", (line) => { - child.stdin.write(`${rewriteLine(line, mcpServers)}\n`); -}); -input.on("close", () => { - child.stdin.end(); -}); +function main() { + const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2)); + const target = splitCommandLine(targetCommand); + const child = spawn(target.command, target.args, { + stdio: ["pipe", "pipe", "inherit"], + env: process.env, + }); -child.stdout.pipe(process.stdout); - -child.on("error", (error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); -}); - -child.on("close", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; + if (!child.stdin || !child.stdout) { + throw new Error("Failed to create MCP proxy stdio pipes"); } - process.exit(code ?? 0); -}); + + const input = createInterface({ input: process.stdin }); + input.on("line", (line) => { + child.stdin.write(`${rewriteLine(line, mcpServers)}\n`); + }); + input.on("close", () => { + child.stdin.end(); + }); + + child.stdout.pipe(process.stdout); + + child.on("error", (error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); + + child.on("close", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +if (isMainModule()) { + main(); +} diff --git a/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts b/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts index 2864645d48d..b2016ec461f 100644 --- a/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts +++ b/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts @@ -2,12 +2,25 @@ import { spawn } from "node:child_process"; import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { bundledPluginFile } from "../../../../test/helpers/bundled-plugin-paths.js"; const tempDirs: string[] = []; const proxyPath = path.resolve(bundledPluginFile("acpx", "src/runtime-internals/mcp-proxy.mjs")); +type SplitCommandLine = ( + value: string, + platform?: NodeJS.Platform | string, +) => { + command: string; + args: string[]; +}; + +async function loadSplitCommandLine(): Promise { + return (await import(pathToFileURL(proxyPath).href)).splitCommandLine as SplitCommandLine; +} + async function makeTempScript(name: string, content: string): Promise { const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-mcp-proxy-")); tempDirs.push(dir); @@ -28,6 +41,42 @@ afterEach(async () => { }); describe("mcp-proxy", () => { + it("parses quoted Windows executable paths without dropping backslashes", async () => { + const splitCommandLine = await loadSplitCommandLine(); + const parsed = splitCommandLine( + '"C:\\Program Files\\Claude\\claude.exe" --stdio --flag "two words"', + "win32", + ); + + expect(parsed).toEqual({ + command: "C:\\Program Files\\Claude\\claude.exe", + args: ["--stdio", "--flag", "two words"], + }); + }); + + it("parses unquoted Windows executable paths without mangling backslashes", async () => { + const splitCommandLine = await loadSplitCommandLine(); + const parsed = splitCommandLine("C:\\Users\\alerl\\.local\\bin\\claude.exe --version", "win32"); + + expect(parsed).toEqual({ + command: "C:\\Users\\alerl\\.local\\bin\\claude.exe", + args: ["--version"], + }); + }); + + it("preserves unquoted Windows path arguments after the executable", async () => { + const splitCommandLine = await loadSplitCommandLine(); + const parsed = splitCommandLine( + '"C:\\Program Files\\Claude\\claude.exe" --config C:\\Users\\me\\cfg.json', + "win32", + ); + + expect(parsed).toEqual({ + command: "C:\\Program Files\\Claude\\claude.exe", + args: ["--config", "C:\\Users\\me\\cfg.json"], + }); + }); + it("injects configured MCP servers into ACP session bootstrap requests", async () => { const echoServerPath = await makeTempScript( "echo-server.cjs",