Files
OmniRoute/tests/unit/services/installers/runNpm-shell-5379.test.ts
Austin Liu 5660bdefbd fix(windows): add windowsHide to all child process spawns (#8131) (#8167)
* fix(windows): add windowsHide to all child process spawns (#8131)

On Windows, child processes spawned without windowsHide: true cause
transient conhost.exe/cmd console windows to flash open. Audited all
spawn/exec/execFile/execSync/execFileSync call sites and added
windowsHide: true where missing.

Files patched:
- src/mitm/manager.ts (MITM server spawn)
- src/mitm/systemCommands.ts (sudo/system command spawn)
- src/mitm/inspector/systemProxyConfig.ts (execFile wrapper)
- src/shared/services/cliRuntime.ts (CLI spawn + npm execFileSync)
- src/lib/plugins/loader.ts (plugin host spawn)
- src/lib/providerModels/cursorAgent.ts (cursor binary spawn)
- src/lib/cloudflaredTunnel.ts (cloudflared spawn)

Unix-only call sites (shell: /bin/bash, which) are unaffected.
electron/main.js already had windowsHide: true.

* fix(windows): cover remaining spawn sites missed by #8131 windowsHide sweep

Extends the #8131 windowsHide audit to the three call sites the original
sweep missed: ServiceSupervisor.start() and processManager.startProcess()
(both spawn() embedded-service child processes), and
installers/utils.ts::buildNpmExecOptions() (the execFile() options runNpm()
uses to install services). All three now always set windowsHide: true so
no transient conhost.exe/cmd console window flashes open on Windows.

The two spawn() options objects are factored into small, pure, exported
builder functions (buildServiceSpawnOptions, buildCliproxyapiSpawnOptions)
so the regression test can assert on the constructed options directly,
since both call sites use a bare named `import { spawn } from
"node:child_process"` that ESM live-binding semantics make unmockable
without --experimental-test-module-mocks (not currently enabled repo-wide).

Bumps config/quality/file-size-baseline.json for cloudflaredTunnel.ts
934->935 (the PR's own +1 windowsHide line at the existing spawn options
object).

Co-Authored-By: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local>
Co-authored-by: Probe Test <probe@example.com>
Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-22 18:08:15 -03:00

85 lines
3.5 KiB
TypeScript

/**
* Regression tests for #5379 — `spawn EINVAL` installing embedded services
* (9Router / CLIProxy) on Windows + Node.js 24+.
*
* Node 24 no longer lets `child_process.execFile()` run `.cmd` batch files on
* Windows without a shell (nodejs/node#52554). npm on Windows is `npm.cmd`, so
* `runNpm()` threw `EINVAL` immediately. The fix flips `shell` on win32.
*
* Because `shell: true` makes the shell — not execFile — parse the command line,
* NO runtime value may be interpolated into argv (Hard Rule #13). The install
* `--prefix` (a DATA_DIR path that can contain spaces, e.g.
* `C:\Users\John Doe\.omniroute\…`) is therefore passed via the
* `npm_config_prefix` environment variable instead of an argv entry, and the
* user-supplied install `version` is constrained by SERVICE_VERSION_PATTERN.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
buildNpmExecOptions,
SERVICE_VERSION_PATTERN,
} from "../../../../src/lib/services/installers/utils.ts";
test("buildNpmExecOptions: win32 enables shell so npm.cmd runs on Node 24 (#5379)", () => {
const opts = buildNpmExecOptions("win32", { timeoutMs: 1000 });
assert.equal(opts.shell, true);
});
test("buildNpmExecOptions: non-win32 platforms never enable shell", () => {
for (const platform of ["linux", "darwin", "freebsd"] as NodeJS.Platform[]) {
const opts = buildNpmExecOptions(platform, { timeoutMs: 1000 });
assert.equal(opts.shell, undefined, `${platform} must not use a shell`);
}
});
test("buildNpmExecOptions: prefix is passed via npm_config_prefix env, never argv (Hard Rule #13)", () => {
const prefix = "C:\\Users\\John Doe\\.omniroute\\services\\9router";
const opts = buildNpmExecOptions("win32", { timeoutMs: 1000, prefix });
assert.equal(opts.env.npm_config_prefix, prefix);
});
test("buildNpmExecOptions: without a prefix npm_config_prefix is left untouched", () => {
const inherited = process.env.npm_config_prefix;
const opts = buildNpmExecOptions("linux", { timeoutMs: 1000 });
assert.equal(opts.env.npm_config_prefix, inherited);
});
test("buildNpmExecOptions: carries cwd, timeout and maxBuffer through", () => {
const opts = buildNpmExecOptions("linux", { cwd: "/tmp/install", timeoutMs: 4242 });
assert.equal(opts.cwd, "/tmp/install");
assert.equal(opts.timeout, 4242);
assert.equal(opts.maxBuffer, 10 * 1024 * 1024);
});
// Regression for #8131 — windowsHide: true must be set on every execFile/spawn
// options object so Windows does not flash a transient conhost.exe/cmd window
// for the npm child process runNpm() spawns.
test("buildNpmExecOptions: windowsHide is always true regardless of platform", () => {
for (const platform of ["win32", "linux", "darwin", "freebsd"] as NodeJS.Platform[]) {
const opts = buildNpmExecOptions(platform, { timeoutMs: 1000 });
assert.equal(opts.windowsHide, true, `${platform} must set windowsHide: true (#8131)`);
}
});
test("SERVICE_VERSION_PATTERN: accepts dist-tags and semver", () => {
for (const v of ["latest", "next", "1.2.3", "1.2.3-beta.1", "1.2.3+build.5", "0.4.59"]) {
assert.ok(SERVICE_VERSION_PATTERN.test(v), `${v} should be valid`);
}
});
test("SERVICE_VERSION_PATTERN: rejects shell metacharacters (injection guard)", () => {
for (const v of [
"latest && calc",
"1.2.3; rm -rf /",
"$(whoami)",
"`id`",
"a|b",
"a b",
"",
"-flag",
]) {
assert.equal(SERVICE_VERSION_PATTERN.test(v), false, `${JSON.stringify(v)} must be rejected`);
}
});