[GH-ISSUE #504] [Bug] rea doctor aborts with spawn EINVAL on Windows: npm.cmd/npx.cmd version probe spawned without a shell #272

Closed
opened 2026-10-15 18:23:16 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @X1Z2-sys on GitHub (Oct 5, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/504

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

CLI: doctor (runtime executable diagnostics)

Observed behavior

On Windows x64, rea doctor produces no diagnostics. The whole command aborts with:

{
  "code": "UNKNOWN",
  "message": "spawn EINVAL"
}

--format json gives the same result, and nothing in the output points to which check failed.

Reproduction

  1. Windows 11 x64, Node.js 24.19.0 (inside the supported ^22.19.0 || >=24.11.0 range), npm on PATH (as usual on Windows, it resolves to npm.cmd).
  2. npm install --prefix <dir> rea-agents@3.2.1
  3. node <dir>/node_modules/rea-agents/scripts/rea.mjs doctor
  4. Result: the spawn EINVAL output above.

The cause reproduces on its own, without REA:

// repro.cjs (run with: node repro.cjs on Windows)
const { spawn, execSync } = require("child_process");
const npm = execSync("where npm", { encoding: "utf8" })
  .split(/\r?\n/).find((l) => /\.cmd$/i.test(l));
let errorEvent = false;
try {
  const c = spawn(npm, ["--version"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
  c.once("error", () => (errorEvent = true));
  console.log("spawn returned without throwing");
} catch (e) {
  console.log("synchronous throw:", e.code, e.syscall, "| 'error' event fired:", errorEvent);
}
new Promise(() => spawn(npm, ["--version"]))
  .catch((e) => console.log("promise rejected with:", e.message));

Output on Node 24.19.0:

synchronous throw: EINVAL spawn | 'error' event fired: false
promise rejected with: spawn EINVAL

Expected behavior

doctor completes and reports each node/npm/npx candidate with its version. A candidate that can't be probed should show up as an unhealthy entry with a typed failure, not abort the whole command.

Impact and scope

  • Affects every Windows user on Node ≥ 18.20.2 / 20.12.2 / 21.7.3, i.e. every supported Node version. On Windows, npm/npx are always .cmd shims.
  • doctor is the command README, docs/windows-ghidra-p0.md, and several remediation messages tell users to run (e.g. "Review the reported local diagnostics or run rea doctor, then retry."). On Windows that advice leads to an opaque crash, which makes the Windows Ghidra P0 boundary hard to diagnose.
  • Analysis commands are not affected (e.g. inspect-managed-artifact, inspect-managed-members, the MCP server). Only doctor is.

REA version

3.2.1 (npm). The same code is still on main at 405732a.

Runtime and platform

  • Windows 11 x64
  • Node.js 24.19.0 (also reproduced with a Node 26.x on PATH)
  • npm 11.17.0 / 11.19.0 / 12.0.2 (three npm.cmd shims on PATH; all affected)

Target and artifact context

None needed. The failure happens before any target is opened.

Interface

CLI

Earliest failure stage

Environment / runtime diagnostics, before any provider or target checks.

Relevant output or evidence

src/application/RuntimeExecutableDiagnostics.ts on main, lines 178–206:

const executeVersion = (path, timeoutMs, effectivePath): Promise<VersionResult> =>
  new Promise((resolve) => {
    const child = spawn(path, ["--version"], {   // <- throws synchronously for *.cmd on win32
      windowsHide: true,
      env: environmentWithPath(effectivePath),
      stdio: ["ignore", "pipe", "pipe"],
    });
    ...
    child.once("error", (cause: Error) => {      // <- never reached for this failure

discoverToolCandidates deliberately includes .BAT/.CMD in the Windows extension list, so on Windows npm and npx candidates are always .cmd files.

Typed outcome or recovery expected

  • A failed probe should resolve as { ok: false, failure: ... }, using a typed code such as the existing classifyProbeFailure path or a new runtime_spawn_failed.
  • .cmd/.bat candidates should be probed through cmd.exe so they report real versions.

Analysis or suspected cause

Since the CVE-2024-27980 hardening (Node 18.20.2 / 20.12.2 / 21.7.3+), child_process.spawn on Windows refuses .bat/.cmd files unless shell: true is set. It throws EINVAL synchronously instead of emitting 'error'. In executeVersion the throw happens inside the new Promise executor, so it becomes a rejection that bypasses the 'error' handler and the resolve({ ok: false, ... }) classification. The rejection then propagates through mapConcurrentBounded → inspectRuntimeExecutables → doctor.

Suggested fix, verified locally. I applied the change below to the compiled dist/ of 3.2.1. With it, doctor completed and probed all 9 node/npm/npx candidates as healthy=true with correct versions (npm 11.17.0, 11.19.0, 12.0.2). The overall verdict then correctly reported the remaining real environment findings (no Hopper, Ghidra not configured in that shell, no agent registrations) instead of crashing.

const isWindowsBatch = (path: string) =>
  process.platform === "win32" && /\.(cmd|bat)$/i.test(path);

const executeVersion = (path, timeoutMs, effectivePath): Promise<VersionResult> =>
  new Promise((resolve) => {
    const options = {
      windowsHide: true,
      env: environmentWithPath(effectivePath),
      stdio: ["ignore", "pipe", "pipe"] as const,
    };
    let child: ChildProcess;
    try {
      child = isWindowsBatch(path)
        ? spawn(process.env.ComSpec ?? "cmd.exe",
            ["/d", "/s", "/c", `""${path}" --version"`],
            { ...options, windowsVerbatimArguments: true })
        : spawn(path, ["--version"], options);
    } catch (cause) {
      resolve({ ok: false, failure: /* typed failure, e.g. runtime_spawn_failed */ });
      return;
    }
    // ...rest unchanged

Notes on the fix:

  • The try/catch is worth having on its own: any future synchronous spawn failure on any platform then becomes one unhealthy candidate instead of a crashed doctor.
  • cmd.exe /d /s /c ""<path>" --version" with windowsVerbatimArguments is the standard quoting for a path that may contain spaces (e.g. C:\Program Files\nodejs\npm.cmd). /d skips AutoRun. The path comes from REA's own PATH discovery, not from user input. If you'd rather keep shell interpretation out entirely, the project's existing approach in docs/windows-ghidra-p0.md (reject paths with command-interpreter metacharacters) could be reused here too.
  • Upgrade.ts also calls runCommand("npm", ...) / execFileAsync("npm", ...). On Windows, execFile("npm") without a shell fails to find npm.cmd for the same underlying reason, so rea upgrade may need the same treatment. I didn't exercise upgrade, so I haven't verified that path.

Additional context

A regression test could run on the existing Windows CI lane: create a temporary fake.cmd that echoes a version, put it first on a PATH passed to inspectRuntimeExecutables, and assert the call resolves with that candidate rather than rejecting.

Originally created by @X1Z2-sys on GitHub (Oct 5, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/504 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area CLI: `doctor` (runtime executable diagnostics) ### Observed behavior On Windows x64, `rea doctor` produces no diagnostics. The whole command aborts with: ```json { "code": "UNKNOWN", "message": "spawn EINVAL" } ``` `--format json` gives the same result, and nothing in the output points to which check failed. ### Reproduction 1. Windows 11 x64, Node.js 24.19.0 (inside the supported `^22.19.0 || >=24.11.0` range), npm on `PATH` (as usual on Windows, it resolves to `npm.cmd`). 2. `npm install --prefix <dir> rea-agents@3.2.1` 3. `node <dir>/node_modules/rea-agents/scripts/rea.mjs doctor` 4. Result: the `spawn EINVAL` output above. The cause reproduces on its own, without REA: ```js // repro.cjs (run with: node repro.cjs on Windows) const { spawn, execSync } = require("child_process"); const npm = execSync("where npm", { encoding: "utf8" }) .split(/\r?\n/).find((l) => /\.cmd$/i.test(l)); let errorEvent = false; try { const c = spawn(npm, ["--version"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); c.once("error", () => (errorEvent = true)); console.log("spawn returned without throwing"); } catch (e) { console.log("synchronous throw:", e.code, e.syscall, "| 'error' event fired:", errorEvent); } new Promise(() => spawn(npm, ["--version"])) .catch((e) => console.log("promise rejected with:", e.message)); ``` Output on Node 24.19.0: ``` synchronous throw: EINVAL spawn | 'error' event fired: false promise rejected with: spawn EINVAL ``` ### Expected behavior `doctor` completes and reports each `node`/`npm`/`npx` candidate with its version. A candidate that can't be probed should show up as an unhealthy entry with a typed failure, not abort the whole command. ### Impact and scope - Affects every Windows user on Node ≥ 18.20.2 / 20.12.2 / 21.7.3, i.e. every supported Node version. On Windows, `npm`/`npx` are always `.cmd` shims. - `doctor` is the command README, `docs/windows-ghidra-p0.md`, and several remediation messages tell users to run (e.g. *"Review the reported local diagnostics or run `rea doctor`, then retry."*). On Windows that advice leads to an opaque crash, which makes the Windows Ghidra P0 boundary hard to diagnose. - Analysis commands are not affected (e.g. `inspect-managed-artifact`, `inspect-managed-members`, the MCP server). Only `doctor` is. ### REA version 3.2.1 (npm). The same code is still on `main` at `405732a`. ### Runtime and platform - Windows 11 x64 - Node.js 24.19.0 (also reproduced with a Node 26.x on `PATH`) - npm 11.17.0 / 11.19.0 / 12.0.2 (three `npm.cmd` shims on `PATH`; all affected) ### Target and artifact context None needed. The failure happens before any target is opened. ### Interface CLI ### Earliest failure stage Environment / runtime diagnostics, before any provider or target checks. ### Relevant output or evidence `src/application/RuntimeExecutableDiagnostics.ts` on `main`, lines 178–206: ```ts const executeVersion = (path, timeoutMs, effectivePath): Promise<VersionResult> => new Promise((resolve) => { const child = spawn(path, ["--version"], { // <- throws synchronously for *.cmd on win32 windowsHide: true, env: environmentWithPath(effectivePath), stdio: ["ignore", "pipe", "pipe"], }); ... child.once("error", (cause: Error) => { // <- never reached for this failure ``` `discoverToolCandidates` deliberately includes `.BAT`/`.CMD` in the Windows extension list, so on Windows `npm` and `npx` candidates are always `.cmd` files. ### Typed outcome or recovery expected - A failed probe should resolve as `{ ok: false, failure: ... }`, using a typed code such as the existing `classifyProbeFailure` path or a new `runtime_spawn_failed`. - `.cmd`/`.bat` candidates should be probed through `cmd.exe` so they report real versions. ### Analysis or suspected cause Since the CVE-2024-27980 hardening (Node 18.20.2 / 20.12.2 / 21.7.3+), `child_process.spawn` on Windows refuses `.bat`/`.cmd` files unless `shell: true` is set. It throws `EINVAL` **synchronously** instead of emitting `'error'`. In `executeVersion` the throw happens inside the `new Promise` executor, so it becomes a rejection that bypasses the `'error'` handler and the `resolve({ ok: false, ... })` classification. The rejection then propagates through `mapConcurrentBounded` → `inspectRuntimeExecutables` → `doctor`. **Suggested fix, verified locally.** I applied the change below to the compiled `dist/` of 3.2.1. With it, `doctor` completed and probed all 9 `node`/`npm`/`npx` candidates as `healthy=true` with correct versions (npm 11.17.0, 11.19.0, 12.0.2). The overall verdict then correctly reported the remaining real environment findings (no Hopper, Ghidra not configured in that shell, no agent registrations) instead of crashing. ```ts const isWindowsBatch = (path: string) => process.platform === "win32" && /\.(cmd|bat)$/i.test(path); const executeVersion = (path, timeoutMs, effectivePath): Promise<VersionResult> => new Promise((resolve) => { const options = { windowsHide: true, env: environmentWithPath(effectivePath), stdio: ["ignore", "pipe", "pipe"] as const, }; let child: ChildProcess; try { child = isWindowsBatch(path) ? spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `""${path}" --version"`], { ...options, windowsVerbatimArguments: true }) : spawn(path, ["--version"], options); } catch (cause) { resolve({ ok: false, failure: /* typed failure, e.g. runtime_spawn_failed */ }); return; } // ...rest unchanged ``` Notes on the fix: - The `try/catch` is worth having on its own: any future synchronous spawn failure on any platform then becomes one unhealthy candidate instead of a crashed `doctor`. - `cmd.exe /d /s /c ""<path>" --version"` with `windowsVerbatimArguments` is the standard quoting for a path that may contain spaces (e.g. `C:\Program Files\nodejs\npm.cmd`). `/d` skips AutoRun. The path comes from REA's own `PATH` discovery, not from user input. If you'd rather keep shell interpretation out entirely, the project's existing approach in `docs/windows-ghidra-p0.md` (reject paths with command-interpreter metacharacters) could be reused here too. - `Upgrade.ts` also calls `runCommand("npm", ...)` / `execFileAsync("npm", ...)`. On Windows, `execFile("npm")` without a shell fails to find `npm.cmd` for the same underlying reason, so `rea upgrade` may need the same treatment. I didn't exercise `upgrade`, so I haven't verified that path. ### Additional context A regression test could run on the existing Windows CI lane: create a temporary `fake.cmd` that echoes a version, put it first on a `PATH` passed to `inspectRuntimeExecutables`, and assert the call resolves with that candidate rather than rejecting.
bsakaguchi 2026-10-15 18:23:16 +00:00
Author
Owner

@morluto commented on GitHub (Oct 5, 2026):

Resolved by #505, merged to main.

The version probe now routes .cmd/.bat candidates through cmd.exe with windowsVerbatimArguments, and platform/systemRoot are injected so the behaviour is unit-testable off Windows. Embedded quotes in a path are doubled so a crafted PATH entry cannot break out of the /c string.

Verified on main: windowsBatchCommand(path, platform) at src/application/RuntimeExecutableDiagnostics.ts:201, with the helper and its test-only export at :272 and :289. The replacement test suite covers the pure command construction (extension matching, paths with spaces, quote escaping, explicit SystemRoot) plus a Windows-only live .bat probe.

Closing.

<!-- gh-comment-id:5994958082 --> @morluto commented on GitHub (Oct 5, 2026): Resolved by #505, merged to main. The version probe now routes `.cmd`/`.bat` candidates through `cmd.exe` with `windowsVerbatimArguments`, and platform/systemRoot are injected so the behaviour is unit-testable off Windows. Embedded quotes in a path are doubled so a crafted PATH entry cannot break out of the `/c` string. Verified on main: `windowsBatchCommand(path, platform)` at `src/application/RuntimeExecutableDiagnostics.ts:201`, with the helper and its test-only export at `:272` and `:289`. The replacement test suite covers the pure command construction (extension matching, paths with spaces, quote escaping, explicit `SystemRoot`) plus a Windows-only live `.bat` probe. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#272
No description provided.