refactor(acpx): split Windows command parsing

This commit is contained in:
Peter Steinberger
2026-04-04 14:18:26 +09:00
parent 41243529fb
commit e4dc03f108
5 changed files with 184 additions and 159 deletions

View File

@@ -0,0 +1,123 @@
const WINDOWS_DIRECT_EXECUTABLE_PATH_RE =
/^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
// Windows wrapper scripts need their host shell or interpreter (`cmd.exe`,
// `powershell.exe`, or `node`) instead of direct spawning.
const WINDOWS_WRAPPER_PATH_RE =
/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|js|mjs|ps1)$/i;
function splitCommandParts(value, platform = process.platform) {
const parts = [];
let current = "";
let quote = null;
let escaping = false;
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 === "\\") {
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;
}
if (quote) {
if (ch === quote) {
quote = null;
} else {
current += ch;
}
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current.length > 0) {
parts.push(current);
current = "";
}
continue;
}
current += ch;
}
if (escaping) {
current += "\\";
}
if (quote) {
throw new Error("Invalid agent command: unterminated quote");
}
if (current.length > 0) {
parts.push(current);
}
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_DIRECT_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) : [],
};
}
function assertSupportedWindowsCommand(command, platform = process.platform) {
if (platform !== "win32" || !WINDOWS_WRAPPER_PATH_RE.test(command)) {
return;
}
throw new Error(
`Unsupported Windows agent command wrapper: ${command}. ` +
"Invoke wrapper scripts through their shell or interpreter instead " +
"(for example `cmd.exe /c`, `powershell.exe -File`, or `node <script>`).",
);
}
export function splitCommandLine(value, platform = process.platform) {
const windowsCommand = splitWindowsExecutableCommand(value, platform);
const parts = windowsCommand ?? splitCommandParts(value, platform);
if (parts.length === 0) {
throw new Error("Invalid agent command: empty command");
}
const parsed = Array.isArray(parts)
? {
command: parts[0],
args: parts.slice(1),
}
: parts;
assertSupportedWindowsCommand(parsed.command, platform);
return parsed;
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
type SplitCommandLine = (
value: string,
platform?: NodeJS.Platform | string,
) => {
command: string;
args: string[];
};
async function loadSplitCommandLine(): Promise<SplitCommandLine> {
const moduleUrl = new URL("./mcp-command-line.mjs", import.meta.url);
return (await import(moduleUrl.href)).splitCommandLine as SplitCommandLine;
}
describe("mcp-command-line", () => {
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("rejects direct Windows wrapper-script commands with a helpful error", async () => {
const splitCommandLine = await loadSplitCommandLine();
expect(() =>
splitCommandLine('"C:\\Users\\me\\bin\\claude-wrapper.cmd" --stdio', "win32"),
).toThrow(/Invoke wrapper scripts through their shell or interpreter instead/);
});
});

View File

@@ -4,116 +4,7 @@ import { spawn } from "node:child_process";
import path from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
const WINDOWS_EXECUTABLE_PATH_RE =
/^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
function splitCommandParts(value, platform = process.platform) {
const parts = [];
let current = "";
let quote = null;
let escaping = false;
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 === "\\") {
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;
}
if (quote) {
if (ch === quote) {
quote = null;
} else {
current += ch;
}
continue;
}
if (ch === "'" || ch === '"') {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current.length > 0) {
parts.push(current);
current = "";
}
continue;
}
current += ch;
}
if (escaping) {
current += "\\";
}
if (quote) {
throw new Error("Invalid agent command: unterminated quote");
}
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");
}
return {
command: parts[0],
args: parts.slice(1),
};
}
import { splitCommandLine } from "./mcp-command-line.mjs";
function decodePayload(argv) {
const payloadIndex = argv.indexOf("--payload");

View File

@@ -2,25 +2,12 @@ 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<SplitCommandLine> {
return (await import(pathToFileURL(proxyPath).href)).splitCommandLine as SplitCommandLine;
}
async function makeTempScript(name: string, content: string): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-mcp-proxy-"));
tempDirs.push(dir);
@@ -41,42 +28,6 @@ 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",