Files
get-shit-done/tests/bug-2598-windows-npm-spawn-shell.test.cjs
Tom Boucher c90081176d fix(#2598): pass shell: true to npm spawnSync on Windows (#2600)
* fix(#2598): pass shell: true to npm spawnSync on Windows

Since Node's CVE-2024-27980 fix (>= 18.20.2 / >= 20.12.2 / >= 21.7.3),
spawnSync refuses to launch .cmd/.bat files on Windows without
`shell: true`. installSdkIfNeeded picks npmCmd='npm.cmd' on win32 and
then calls spawnSync five times — every one returns
{ status: null, error: EINVAL } before npm ever runs. The installer
checks `status !== 0`, trips the failure path, and emits a bare
"Failed to `npm install` in sdk/." with zero diagnostic output because
`stdio: 'inherit'` never had a child to stream.

Every fresh install on Windows has failed at the SDK build step on any
supported Node version for the life of the post-CVE bin/install.js.

Introduce a local `spawnNpm(args, opts)` helper inside
installSdkIfNeeded that injects `shell: process.platform === 'win32'`
when the caller doesn't override it. Route all five npm invocations
through it: `npm install`, `npm run build`, `npm install -g .`, and
both `npm config get prefix` calls.

Adds a static regression test that parses installSdkIfNeeded and
asserts no bare `spawnSync(npmCmd, ...)` remains, a shell-aware
wrapper exists, and at least five invocations go through it.

Closes #2598

* fix(#2598): surface spawnSync diagnostics in SDK install fatal paths

Thread result.error / result.signal / result.status into emitSdkFatal for
the three npm failure branches (install, run build, install -g .) via a
formatSpawnFailure helper. The root cause of #2598 went silent precisely
because `{ status: null, error: EINVAL }` was reduced to a generic
"Failed to `npm install` in sdk/." with no diagnostic — stdio: 'inherit'
had no child process to stream and result.error was swallowed. Any future
regression in the same area (EINVAL, ENOENT, signal termination) now
prints its real cause in the red fatal banner.

Also strengthen the regression test so it cannot pass with only four
real npm call sites: the previous `spawnSync(npmCmd, ..., shell)` regex
double-counted the spawnNpm helper's own body when a helper existed.
Separate arrow-form vs function-form helper detection and exclude the
wrapper body from explicitShellNpm so the `>= 5` assertion reflects real
invocations only. Add a new test that asserts all three fatal branches
now reference formatSpawnFailure / result.error / signal / status.

Addresses CodeRabbit review comments on PR #2600:
- r3126987409 (bin/install.js): surface underlying spawnSync failure
- r3126987419 (test): explicitShellNpm overcounts by one via helper def
2026-04-22 21:23:44 -04:00

139 lines
6.7 KiB
JavaScript

/**
* Regression test for bug #2598
*
* On Windows, Node.js refuses to spawn `.cmd`/`.bat` files via spawnSync
* unless `shell: true` is passed (post CVE-2024-27980, fixed in
* Node >= 18.20.2 / >= 20.12.2 / >= 21.7.3 and every version since).
*
* installSdkIfNeeded() picks `npmCmd = 'npm.cmd'` on win32 and then shells
* out five times: `npm install`, `npm run build`, `npm install -g .`, and
* two `npm config get prefix` calls. Without `shell: true`, every single
* one of those returns `{ status: null, error: EINVAL }` before npm ever
* launches. The installer checks `status !== 0` (null !== 0 is true) and
* trips its failure path — producing a silent SDK build failure with zero
* diagnostic output because `stdio: 'inherit'` never got a child to stream.
*
* Fix: every spawnSync that targets `npmCmd` inside installSdkIfNeeded
* must pass `shell: true` on Windows. The structural invariant verified
* here is that no bare `spawnSync(npmCmd, ...)` remains inside the
* function — all npm invocations go through a helper that injects the
* shell option on win32 (or have it explicitly on the call site).
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
function installSdkBody() {
const src = fs.readFileSync(INSTALL_SRC, 'utf-8');
const fnStart = src.indexOf('function installSdkIfNeeded()');
assert.ok(fnStart !== -1, 'installSdkIfNeeded function must exist in install.js');
const fnEnd = src.indexOf('\nfunction ', fnStart + 1);
return fnEnd !== -1 ? src.slice(fnStart, fnEnd) : src.slice(fnStart);
}
describe('bug #2598: Windows npm.cmd spawnSync must pass shell: true', () => {
test('install.js source exists', () => {
assert.ok(fs.existsSync(INSTALL_SRC), 'bin/install.js must exist');
});
test('installSdkIfNeeded does not call spawnSync(npmCmd, ...) directly', () => {
const body = installSdkBody();
// A bare `spawnSync(npmCmd,` call will fail with EINVAL on Windows
// because npm.cmd requires `shell: true`. Every npm invocation must
// go through a wrapper that injects the shell option on win32.
//
// Exclude the implementation of the wrapper itself (its `spawnSync(npmCmd, args, ...)`
// line is the ONE legitimate place that spawns npmCmd directly — and it forwards
// a shell option from `opts`).
const allCalls = body.match(/spawnSync\s*\(\s*npmCmd[^)]*\)/g) || [];
const bareCalls = allCalls.filter(c => !/\bshell\s*:/.test(c));
assert.equal(
bareCalls.length,
0,
'installSdkIfNeeded must not call spawnSync(npmCmd, ...) directly. ' +
'On Windows, npm.cmd spawns fail with EINVAL (CVE-2024-27980) unless ' +
'shell: true is passed. Route all npm invocations through a helper ' +
'that injects `shell: process.platform === "win32"`.'
);
});
test('installSdkIfNeeded defines a shell-aware npm wrapper', () => {
const body = installSdkBody();
// The canonical implementation introduces `spawnNpm` (or equivalent)
// that injects `shell: true` on win32. Accept either a helper
// function or `shell: true` / `shell: needsShell` / `shell: win32`
// appearing alongside an npm spawn.
const hasHelper = /\bspawnNpm\b/.test(body);
const hasShellOption = /shell\s*:\s*(true|needsShell|process\.platform\s*===\s*['"]win32['"])/.test(body);
assert.ok(
hasHelper || hasShellOption,
'installSdkIfNeeded must introduce a helper or pass shell:true/win32-aware ' +
'shell option for npm spawnSync calls. Found neither.'
);
});
test('installSdkIfNeeded calls the npm wrapper at least five times', () => {
const body = installSdkBody();
// Five documented npm invocations: install, run build, install -g .,
// and two config get prefix calls. If any are still bare spawnSync
// calls targeting npmCmd, the first assertion above already fails.
// `\bspawnNpm\s*\(` matches real call sites only — a `const spawnNpm = (…)`
// declaration does not match because `=` sits between name and `(`. A
// `function spawnNpm(…)` declaration WOULD match, so subtract one for that
// form. `explicitShellNpm` previously double-counted the wrapper's own
// `spawnSync(npmCmd, args, { …, shell })` — when a helper exists, its body
// is the only legitimate raw spawn and must be excluded.
const wrapperCallMatches = (body.match(/\bspawnNpm\s*\(/g) || []).length;
const hasArrowHelper = /\b(?:const|let|var)\s+spawnNpm\s*=/.test(body);
const hasFunctionHelper = /\bfunction\s+spawnNpm\s*\(/.test(body);
const hasHelper = hasArrowHelper || hasFunctionHelper;
const wrapperCalls = hasFunctionHelper
? Math.max(0, wrapperCallMatches - 1)
: wrapperCallMatches;
const explicitShellNpm = hasHelper
? 0
: (body.match(/spawnSync\s*\(\s*npmCmd[^)]*\bshell\s*:/g) || []).length;
const total = wrapperCalls + explicitShellNpm;
assert.ok(
total >= 5,
`installSdkIfNeeded should route at least 5 npm invocations through ` +
`a shell-aware wrapper (install, run build, install -g ., and two ` +
`config get prefix calls). Found ${total}.`
);
});
test('installSdkIfNeeded surfaces underlying spawnSync failure in fatal branches', () => {
// Root cause of #2598 was invisible because `{ status: null, error: EINVAL }`
// was reduced to a generic "Failed to `npm install` in sdk/." with no
// diagnostic — stdio: 'inherit' had no child to stream and result.error was
// dropped. Each of the three `emitSdkFatal` calls inside the install/build/
// global-install failure paths must now thread spawn diagnostics (error,
// signal, or numeric status) into the reason string so future regressions
// print their real cause instead of failing silently.
const body = installSdkBody();
const hasFormatter = /formatSpawnFailure\s*\(/.test(body);
const fatalCalls = body.match(/emitSdkFatal\s*\([^)]*`[^`]*`[^)]*\)/gs) || [];
const fatalWithDiagnostic = fatalCalls.filter(
(c) => /formatSpawnFailure|\.error|\.signal|\.status/.test(c),
);
assert.ok(
hasFormatter,
'installSdkIfNeeded must define a spawn-failure formatter so fatal ' +
'npm failures surface result.error / result.signal / result.status ' +
'instead of swallowing them (root cause of #2598 being invisible).',
);
assert.ok(
fatalWithDiagnostic.length >= 3,
`At least 3 emitSdkFatal calls (install, build, install -g .) must ` +
`include spawn diagnostics. Found ${fatalWithDiagnostic.length} that ` +
`reference formatSpawnFailure or result.error/signal/status.`,
);
});
});