From 917c2f56f40e1320daa1fb67348989397e8766a8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Apr 2026 16:36:57 +0700 Subject: [PATCH] test(server): reproduce Windows spawn EINVAL with cmd shim + spaces + JSON args --- .github/workflows/ci.yml | 2 + .../providers/provider-windows-launch.test.ts | 217 ++++++++++++++++++ .../src/utils/spawn.launch-regression.test.ts | 171 ++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 packages/server/src/server/agent/providers/provider-windows-launch.test.ts create mode 100644 packages/server/src/utils/spawn.launch-regression.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8f438ed8..3d04b0710 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,7 @@ jobs: run: > npx vitest run src/utils/executable.test.ts + src/utils/spawn.launch-regression.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/utils/checkout-git-rev-parse.test.ts @@ -109,6 +110,7 @@ jobs: src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/agent/providers/claude-agent.spawn.test.ts + src/server/agent/providers/provider-windows-launch.test.ts src/server/agent/providers/provider-availability.test.ts src/server/workspace-registry-model.test.ts src/server/persisted-config.test.ts diff --git a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts new file mode 100644 index 000000000..c099b2561 --- /dev/null +++ b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts @@ -0,0 +1,217 @@ +import type { ChildProcess } from "node:child_process"; +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; + +import { findExecutable } from "../../../utils/executable.js"; +import { spawnProcess } from "../../../utils/spawn.js"; + +type SpawnResult = { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + error: Error | null; +}; + +type ProviderLaunchCase = { + provider: "claude" | "codex" | "opencode" | "generic-acp"; + binaryName: string; + args: string[]; + shell?: boolean; +}; + +const JSON_ARG = '{"mcpServers":{"paseo":{"type":"http","url":"http://127.0.0.1:6767/mcp"}}}'; +const tempDirs: string[] = []; + +function makeFixture(binaryName: string, expectedArgs: string[]) { + const root = mkdtempSync(path.join(tmpdir(), `paseo ${binaryName} launch `)); + tempDirs.push(root); + + const fakeDaemonNode = path.join(root, "Fake Paseo.exe"); + copyFileSync(process.execPath, fakeDaemonNode); + + const assertScript = path.join(root, "assert-argv.js"); + writeFileSync( + assertScript, + ` +if (process.argv.includes("--version")) { + console.log("fake-provider 1.0.0"); + process.exit(0); +} + +const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON); +const actual = process.argv.slice(2); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + console.error("ARGV_MISMATCH"); + console.error(JSON.stringify({ expected, actual })); + process.exit(42); +} +console.log("ARGV_OK"); +`, + ); + + const shim = path.join(root, `${binaryName}.cmd`); + writeFileSync( + shim, + ["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"), + ); + + return { root, shim, expectedArgs }; +} + +function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise { + return new Promise((resolve) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let error: Error | null = null; + let settled = false; + + const settle = (result: Pick) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + ...result, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + error, + }); + }; + + const timer = setTimeout(() => { + child.kill("SIGKILL"); + settle({ code: null, signal: "SIGKILL" }); + }, timeoutMs); + timer.unref?.(); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once("error", (err) => { + error = err; + settle({ code: null, signal: null }); + }); + child.once("exit", (code, signal) => { + settle({ code, signal }); + }); + }); +} + +async function runProviderFixture(params: { + command: string; + args: string[]; + expectedArgs: string[]; + shell?: boolean; +}): Promise { + const child = spawnProcess(params.command, params.args, { + env: { + ...process.env, + PASEO_EXPECTED_ARGV_JSON: JSON.stringify(params.expectedArgs), + }, + stdio: ["ignore", "pipe", "pipe"], + ...(params.shell === undefined ? {} : { shell: params.shell }), + }); + return collectChild(child); +} + +function withPathEntry(dir: string, run: () => Promise): Promise { + const pathKey = + process.platform === "win32" + ? (Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "Path") + : "PATH"; + const previousPath = process.env[pathKey]; + process.env[pathKey] = previousPath ? `${dir}${path.delimiter}${previousPath}` : dir; + + return run().finally(() => { + if (previousPath === undefined) { + delete process.env[pathKey]; + } else { + process.env[pathKey] = previousPath; + } + }); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const providerLaunchCases: ProviderLaunchCase[] = [ + { + provider: "claude", + binaryName: "claude", + args: ["--mcp-config", JSON_ARG], + shell: false, + }, + { + provider: "codex", + binaryName: "codex", + args: ["app-server", "--config", JSON_ARG], + }, + { + provider: "opencode", + binaryName: "opencode", + args: ["serve", "--port", "49271", "--config", JSON_ARG], + }, + { + provider: "generic-acp", + binaryName: "generic-acp", + args: ["--mcp-config", JSON_ARG], + }, +]; + +describe.runIf(process.platform === "win32")("Windows provider launch parity", () => { + test("detected claude.cmd can be launched with Claude's JSON-safe spawn shape", async () => { + const args = ["--mcp-config", JSON_ARG]; + const fixture = makeFixture("claude", args); + + await withPathEntry(fixture.root, async () => { + const detected = await findExecutable("claude"); + expect(detected?.toLowerCase()).toBe(fixture.shim.toLowerCase()); + + const result = await runProviderFixture({ + command: detected!, + args, + expectedArgs: args, + shell: false, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); + }); + + test.each( + providerLaunchCases, + )("$provider launches a cmd shim from a path with spaces through spawnProcess", async ({ + binaryName, + args, + shell, + }) => { + const fixture = makeFixture(binaryName, args); + + const result = await runProviderFixture({ + command: fixture.shim, + args: fixture.expectedArgs, + expectedArgs: fixture.expectedArgs, + shell, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); +}); diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts new file mode 100644 index 000000000..1ef258edf --- /dev/null +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -0,0 +1,171 @@ +import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { ChildProcess } from "node:child_process"; +import { afterEach, describe, expect, test } from "vitest"; + +import { spawnProcess } from "./spawn.js"; + +type SpawnResult = { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + error: Error | null; +}; + +const tempDirs: string[] = []; +const JSON_ARG = '{"key":"value with spaces","nested":{"quote":"\\"yes\\""}}'; + +function makeFixture(): { + root: string; + fakeDaemonNode: string; + shim: string; + assertScript: string; + expectedArgs: string[]; +} { + const root = mkdtempSync(path.join(tmpdir(), "paseo spawn regression ")); + tempDirs.push(root); + + const fakeDaemonNode = path.join(root, "Fake Paseo.exe"); + copyFileSync(process.execPath, fakeDaemonNode); + + const expectedArgs = ["--config", JSON_ARG]; + const assertScript = path.join(root, "assert-argv.js"); + writeFileSync( + assertScript, + ` +const expected = JSON.parse(process.env.PASEO_EXPECTED_ARGV_JSON); +const actual = process.argv.slice(2); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + console.error("ARGV_MISMATCH"); + console.error(JSON.stringify({ expected, actual })); + process.exit(42); +} +console.log("ARGV_OK"); +`, + ); + + const shim = path.join(root, "claude.cmd"); + writeFileSync( + shim, + ["@echo off", "setlocal", `\"${fakeDaemonNode}\" \"${assertScript}\" %*`, ""].join("\r\n"), + ); + + return { root, fakeDaemonNode, shim, assertScript, expectedArgs }; +} + +function collectChild(child: ChildProcess, timeoutMs = 10_000): Promise { + return new Promise((resolve) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let error: Error | null = null; + let settled = false; + + const settle = (result: Pick) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + ...result, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + error, + }); + }; + + const timer = setTimeout(() => { + child.kill("SIGKILL"); + settle({ code: null, signal: "SIGKILL" }); + }, timeoutMs); + timer.unref?.(); + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.once("error", (err) => { + error = err; + settle({ code: null, signal: null }); + }); + child.once("exit", (code, signal) => { + settle({ code, signal }); + }); + }); +} + +async function runFixture(params: { + command: string; + args: string[]; + shell?: boolean; +}): Promise { + const child = spawnProcess(params.command, params.args, { + env: { + ...process.env, + PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]), + }, + stdio: ["ignore", "pipe", "pipe"], + ...(params.shell === undefined ? {} : { shell: params.shell }), + }); + return collectChild(child); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe.runIf(process.platform === "win32")("Windows spawn launch regression", () => { + test("launches a cmd shim from a path with spaces without corrupting JSON args", async () => { + const fixture = makeFixture(); + + const result = await runFixture({ + command: fixture.shim, + args: fixture.expectedArgs, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); + + test("direct launch with a space-containing executable preserves JSON args", async () => { + const fixture = makeFixture(); + + const result = await runFixture({ + command: fixture.fakeDaemonNode, + args: [fixture.assertScript, ...fixture.expectedArgs], + shell: false, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); +}); + +describe.skipIf(process.platform === "win32")("spawn launch regression smoke", () => { + test("direct launch with a space-containing executable works on this platform", async () => { + const fixture = makeFixture(); + + const result = await runFixture({ + command: fixture.fakeDaemonNode, + args: [fixture.assertScript, ...fixture.expectedArgs], + shell: false, + }); + + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }); +});