From e37c2969945f3b6080443516d4159a8cd8a5786e Mon Sep 17 00:00:00 2001 From: priyamkarn Date: Mon, 13 Jul 2026 07:16:15 +0530 Subject: [PATCH] Fix agent-runner spawn() calls failing on Windows for npm-installed CLI tools codex.ts, opencode.ts, and openhands.ts all spawned their respective CLIs via node:child_process's spawn() without shell:true. On Windows, npm-installed global CLI tools are shimmed as .cmd files, which Windows' CreateProcess cannot execute directly -- it requires cmd.exe as an intermediary. Reproduced this directly (spawn EINVAL) while testing a fake opencode.cmd stand-in with the exact same spawn pattern the production code uses. Switches all three runners to cross-spawn, the standard solution for this (same package npm itself uses internally) -- it detects .cmd/.bat targets and wraps them through cmd.exe with correct argument escaping, without needing shell:true and its shell-injection risk. --- libs/agent-runner/codex.ts | 8 +- libs/agent-runner/opencode.ts | 9 ++- libs/agent-runner/openhands.ts | 4 +- package-lock.json | 77 +++++++++++++++++++ package.json | 4 +- scripts/check-agent-runner-cross-spawn.js | 90 +++++++++++++++++++++++ 6 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 scripts/check-agent-runner-cross-spawn.js diff --git a/libs/agent-runner/codex.ts b/libs/agent-runner/codex.ts index 8452c73..abb4973 100644 --- a/libs/agent-runner/codex.ts +++ b/libs/agent-runner/codex.ts @@ -1,5 +1,5 @@ import { createWriteStream } from "node:fs"; -import { spawn } from "node:child_process"; +import spawn from "cross-spawn"; import { collectAgentMetrics } from "./metrics.js"; import type { AgentRunInput, AgentRunResult } from "./types.js"; @@ -57,11 +57,11 @@ function runCodexProcess( ); child.once("error", reject); - child.stdout.pipe(transcript); + child.stdout!.pipe(transcript); // If the child exits before draining the prompt, stdin emits EPIPE; // the failure is reported via the exit code on "close". - child.stdin.once("error", () => {}); - child.stdin.end(input.prompt); + child.stdin!.once("error", () => {}); + child.stdin!.end(input.prompt); child.once("close", (exitCode, signal) => { resolve({ exitCode, signal }); }); diff --git a/libs/agent-runner/opencode.ts b/libs/agent-runner/opencode.ts index ba93e4d..8950cde 100644 --- a/libs/agent-runner/opencode.ts +++ b/libs/agent-runner/opencode.ts @@ -1,5 +1,5 @@ import { createWriteStream, writeFileSync } from "node:fs"; -import { spawn } from "node:child_process"; +import spawn from "cross-spawn"; import { collectAgentMetrics } from "./metrics.js"; import type { AgentRunInput, AgentRunResult } from "./types.js"; @@ -26,21 +26,22 @@ export async function runOpenCodeAgent(input: AgentRunInput): Promise { return new Promise((resolve, reject) => { const args = ["-p", input.prompt, "-f", "json", "-q"]; - const child = spawn("opencode", args, { + const child = spawnImpl("opencode", args, { cwd: input.cwd, env: input.env, stdio: ["ignore", "pipe", "inherit"], }); child.once("error", reject); - child.stdout.pipe(transcript); + child.stdout!.pipe(transcript); child.once("close", (exitCode, signal) => { writeFileSync( input.finalMessagePath, diff --git a/libs/agent-runner/openhands.ts b/libs/agent-runner/openhands.ts index e9fdeb4..dd4ac72 100644 --- a/libs/agent-runner/openhands.ts +++ b/libs/agent-runner/openhands.ts @@ -1,5 +1,5 @@ import { createWriteStream, writeFileSync } from "node:fs"; -import { spawn } from "node:child_process"; +import spawn from "cross-spawn"; import { dirname, join } from "node:path"; import { collectAgentMetrics } from "./metrics.js"; import type { AgentRunInput, AgentRunResult } from "./types.js"; @@ -49,7 +49,7 @@ function runOpenHandsProcess( ); child.once("error", reject); - child.stdout.pipe(transcript); + child.stdout!.pipe(transcript); child.once("close", (exitCode, signal) => { writeFileSync( input.finalMessagePath, diff --git a/package-lock.json b/package-lock.json index 0d8a49e..6593f84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "better-sqlite3": "^12.11.1", "chart.js": "4.4.7", "chartjs-plugin-datalabels": "2.2.0", + "cross-spawn": "^7.0.6", "tree-sitter-wasms": "^0.1.13", "web-tree-sitter": "^0.24.7" }, @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/cross-spawn": "^6.0.6", "@types/node": "^22.15.3", "typescript": "^5.8.3" }, @@ -611,6 +613,16 @@ "@types/node": "*" } }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", @@ -741,6 +753,20 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -987,6 +1013,12 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -1117,6 +1149,15 @@ "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", "license": "MIT" }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/platform": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", @@ -1327,6 +1368,27 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -1496,6 +1558,21 @@ "integrity": "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ==", "license": "MIT" }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 6544d30..8c96ee5 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs", "smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs", "smoke:cursor": "npm run build && node scripts/smoke-cursor-install.mjs", - "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js", + "test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-graph-view-offline-browser.js && node scripts/check-source-memberships.js && node scripts/check-proposal-validate.js && node scripts/check-agent-runner-cross-spawn.js && node scripts/check-bm25-tokenizer.js && node scripts/check-anchor-drift.js && node scripts/check-find-similar-claims.js && node scripts/check-apply-proposal-dedupe.js && node scripts/check-opencode-sqlite-transcript.js", "test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js", "test:repo-context": "npm run build && node scripts/check-repo-context.js", "test:source-memberships": "npm run build && node scripts/check-source-memberships.js", @@ -68,11 +68,13 @@ "better-sqlite3": "^12.11.1", "chart.js": "4.4.7", "chartjs-plugin-datalabels": "2.2.0", + "cross-spawn": "^7.0.6", "tree-sitter-wasms": "^0.1.13", "web-tree-sitter": "^0.24.7" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", + "@types/cross-spawn": "^6.0.6", "@types/node": "^22.15.3", "typescript": "^5.8.3" } diff --git a/scripts/check-agent-runner-cross-spawn.js b/scripts/check-agent-runner-cross-spawn.js new file mode 100644 index 0000000..7c205fc --- /dev/null +++ b/scripts/check-agent-runner-cross-spawn.js @@ -0,0 +1,90 @@ +// Regression test for the cross-spawn fix (Windows .cmd/.bat shim compatibility). +// +// On Windows, npm-installed global CLI tools (like a real `codex`, `opencode`, +// or `openhands`) are shimmed as .cmd files, not native .exe. node:child_process's +// spawn() cannot launch .cmd/.bat files at all without shell:true -- Windows' +// CreateProcess can't execute non-PE files, and .cmd requires cmd.exe as an +// intermediary. cross-spawn detects this and handles it transparently. +// +// This checks three things: +// 1. Each agent runner's source imports spawn from 'cross-spawn', not +// node:child_process -- catches a future accidental revert, since +// cross-spawn's win32-specific resolution logic is a no-op on POSIX and so +// can't be observed behaviorally in this (Linux) test environment. +// 2. The *built* dist output for each runner also references cross-spawn -- +// catches a build/bundler misconfiguration that could silently drop it. +// 3. The opencode runner's injectable spawn seam still produces correct argv +// when exercised end-to-end with a fake spawn implementation. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url); + +const runnerFiles = ["codex.ts", "opencode.ts", "openhands.ts"]; +for (const file of runnerFiles) { + const source = readFileSync(new URL(`../libs/agent-runner/${file}`, import.meta.url), "utf8"); + assert.ok( + /from\s+["']cross-spawn["']/.test(source), + `expected libs/agent-runner/${file} to import spawn from 'cross-spawn', not node:child_process ` + + `(node:child_process's spawn cannot launch .cmd/.bat shims on Windows without shell:true)`, + ); + assert.ok( + !/from\s+["']node:child_process["']/.test(source), + `expected libs/agent-runner/${file} to no longer import spawn from node:child_process`, + ); + + const builtFile = file.replace(/\.ts$/, ".js"); + const builtSource = readFileSync(new URL(`dist/libs/agent-runner/${builtFile}`, root), "utf8"); + assert.ok( + builtSource.includes("cross-spawn"), + `expected the built dist/libs/agent-runner/${builtFile} to reference cross-spawn ` + + `(source check passed but the compiled output didn't carry it over -- build misconfiguration?)`, + ); +} + +// Functional check: exercise the actual injectable seam end-to-end. +const { runOpenCodeProcess } = await import(new URL("dist/libs/agent-runner/opencode.js", root)); + +function fakeSpawn(capturedCalls) { + return (command, args, options) => { + capturedCalls.push({ command, args, options }); + const child = new EventEmitter(); + child.stdout = new PassThrough(); + queueMicrotask(() => { + child.stdout.end(); + child.emit("close", 0, null); + }); + return child; + }; +} + +const runDir = mkdtempSync(join(tmpdir(), "greplica-opencode-runner-test-")); +try { + const input = { + cwd: runDir, + env: process.env, + prompt: "Some task instructions.", + transcriptPath: join(runDir, "agent-events.jsonl"), + finalMessagePath: join(runDir, "final-message.md"), + }; + + const capturedCalls = []; + const transcript = new PassThrough(); + transcript.resume(); + + await runOpenCodeProcess(input, transcript, fakeSpawn(capturedCalls)); + + assert.equal(capturedCalls.length, 1, `expected exactly one spawn() call, got ${capturedCalls.length}`); + assert.equal(capturedCalls[0].command, "opencode"); + assert.deepEqual(capturedCalls[0].args, ["-p", "Some task instructions.", "-f", "json", "-q"]); +} finally { + rmSync(runDir, { recursive: true, force: true }); +} + +console.log("cross-spawn wiring checks passed for:", runnerFiles.join(", "));