Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions libs/agent-runner/codex.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -56,19 +56,14 @@
},
);

let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
transcript.once("close", () => reject(error));
transcript.end();
});
child.stdout.pipe(transcript);
child.once("error", reject);
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) => {
if (spawnError !== undefined) return;

Check failure on line 66 in libs/agent-runner/codex.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'spawnError'.
resolve({ exitCode, signal });
});
});
Expand Down
16 changes: 6 additions & 10 deletions libs/agent-runner/opencode.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -26,28 +26,24 @@
};
}

function runOpenCodeProcess(
export function runOpenCodeProcess(
input: AgentRunInput,
transcript: NodeJS.WritableStream,
spawnImpl: typeof spawn = spawn,
): Promise<{ exitCode: number | null; signal: string | null }> {
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"],
});

let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
transcript.once("close", () => reject(error));
transcript.end();
});
child.stdout.pipe(transcript);
child.once("error", reject);
child.stdout!.pipe(transcript);
child.once("close", (exitCode, signal) => {
if (spawnError !== undefined) return;

Check failure on line 46 in libs/agent-runner/opencode.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'spawnError'.
writeFileSync(
input.finalMessagePath,
`OpenCode update runner exited with code ${exitCode ?? "null"} and signal ${signal ?? "null"}.\n`,
Expand Down
11 changes: 3 additions & 8 deletions libs/agent-runner/openhands.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,15 +48,10 @@
},
);

let spawnError: Error | undefined;
child.once("error", (error) => {
spawnError = error;
transcript.once("close", () => reject(error));
transcript.end();
});
child.stdout.pipe(transcript);
child.once("error", reject);
child.stdout!.pipe(transcript);
child.once("close", (exitCode, signal) => {
if (spawnError !== undefined) return;

Check failure on line 54 in libs/agent-runner/openhands.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find name 'spawnError'.
writeFileSync(
input.finalMessagePath,
`OpenHands update runner exited with code ${exitCode ?? "null"} and signal ${signal ?? "null"}.\n`,
Expand Down
77 changes: 77 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +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-repo-installations.js && node scripts/check-managed-cli.js && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.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 && node scripts/check-agent-runner-spawn-error.js",
"test:managed-collaboration": "npm run build && node scripts/check-managed-collaboration.js && node scripts/check-reconciliation-code-evidence.js",
"test:reconciliation-code-evidence": "npm run build && node scripts/check-reconciliation-code-evidence.js",
"test:repo-installations": "npm run build && node scripts/check-repo-installations.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",
Expand Down Expand Up @@ -72,11 +69,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"
},
Expand Down
90 changes: 90 additions & 0 deletions scripts/check-agent-runner-cross-spawn.js
Original file line number Diff line number Diff line change
@@ -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(", "));
Loading