fix(services): runNpm shell on win32 + prefix via env for Node 24 EINVAL (#5379) (#5474)

Node 24 refuses execFile of npm.cmd without a shell (nodejs/node#52554),
so embedded-service install (9Router/CLIProxy) failed with spawn EINVAL on
Windows. runNpm now enables shell on win32 only; to stay Hard-Rule-#13 safe
under a shell, the install --prefix is passed via npm_config_prefix (env)
instead of an argv path (survives spaces), and the user-supplied version is
constrained by SERVICE_VERSION_PATTERN at the route boundary.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 20:45:30 -03:00
committed by GitHub
parent e9c739184e
commit 29bdb8dfde
7 changed files with 162 additions and 22 deletions

View File

@@ -1,11 +1,15 @@
import { z } from "zod";
import { install, InstallResult } from "@/lib/services/installers/ninerouter";
import { InstallError } from "@/lib/services/installers/utils";
import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const BodySchema = z.object({
version: z.string().optional().default("latest"),
version: z
.string()
.regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed")
.optional()
.default("latest"),
});
export async function POST(request: Request): Promise<Response> {

View File

@@ -1,11 +1,15 @@
import { z } from "zod";
import { install, InstallResult } from "@/lib/services/installers/cliproxy";
import { InstallError } from "@/lib/services/installers/utils";
import { InstallError, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const BodySchema = z.object({
version: z.string().optional().default("latest"),
version: z
.string()
.regex(SERVICE_VERSION_PATTERN, "Invalid version: only letters, digits and . _ + - are allowed")
.optional()
.default("latest"),
});
export async function POST(request: Request): Promise<Response> {

View File

@@ -77,16 +77,10 @@ export async function install(version = "latest"): Promise<InstallResult> {
}
await runNpm(
[
"install",
`${NINEROUTER_PACKAGE}@${version}`,
"--omit=dev",
"--no-audit",
"--no-fund",
"--prefix",
NINEROUTER_INSTALL_DIR,
],
{ cwd: NINEROUTER_INSTALL_DIR }
["install", `${NINEROUTER_PACKAGE}@${version}`, "--omit=dev", "--no-audit", "--no-fund"],
// `--prefix` is passed via `prefix` (→ npm_config_prefix env) instead of an
// argv path so an install dir with spaces survives the Windows shell (#5379).
{ cwd: NINEROUTER_INSTALL_DIR, prefix: NINEROUTER_INSTALL_DIR }
);
const installedVersion = await getInstalledVersion();

View File

@@ -73,10 +73,68 @@ function classifyError(
return new InstallError(raw, `Falha na instalação: ${raw}`, 500);
}
/** Runs npm with the given args array. Never uses shell interpolation. */
/**
* Validates a user-supplied service version (npm dist-tag or semver). Constrained
* to letters, digits and `. _ + -`, with a leading alphanumeric, so the value can
* never carry shell metacharacters once `runNpm` runs under a shell on Windows
* (see `buildNpmExecOptions`). Accepts `latest`, `next`, `1.2.3`, `1.2.3-beta.1`,
* `1.2.3+build.5`; rejects `latest && calc`, `$(id)`, spaces, leading `-`, etc.
*/
export const SERVICE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
export interface NpmExecOptions {
cwd?: string;
timeout: number;
env: NodeJS.ProcessEnv;
maxBuffer: number;
shell?: boolean;
}
/**
* Builds the `execFile` options for {@link runNpm}.
*
* On Windows, npm is `npm.cmd` (a batch wrapper). Node 24 refuses to `execFile`
* a `.cmd` without a shell (nodejs/node#52554 — manifests as `spawn EINVAL`, see
* issue #5379), so we enable `shell` on win32 only.
*
* Enabling the shell means the shell — not `execFile` — splits the command line,
* so NO runtime value may be interpolated into argv (Hard Rule #13). The install
* prefix (a DATA_DIR path that can legitimately contain spaces, e.g.
* `C:\Users\John Doe\.omniroute\…`) is therefore exported as the
* `npm_config_prefix` environment variable — npm's documented env form of
* `--prefix` — never as an argv entry. With the prefix moved to the environment
* and the version constrained by {@link SERVICE_VERSION_PATTERN}, every remaining
* argv entry is a static, metacharacter-free flag.
*/
export function buildNpmExecOptions(
platform: NodeJS.Platform,
options: { cwd?: string; timeoutMs: number; prefix?: string }
): NpmExecOptions {
const env: NodeJS.ProcessEnv = { ...process.env };
if (options.prefix) {
env.npm_config_prefix = options.prefix;
}
const execOptions: NpmExecOptions = {
cwd: options.cwd,
timeout: options.timeoutMs,
env,
maxBuffer: 10 * 1024 * 1024, // 10 MB for npm output
};
if (platform === "win32") {
execOptions.shell = true;
}
return execOptions;
}
/**
* Runs npm with the given args array. Never uses shell interpolation: argv holds
* only static flags, and any install prefix is passed via `options.prefix`
* (exported as `npm_config_prefix`), not as an argv path. See
* {@link buildNpmExecOptions} for the Windows/Node-24 shell handling.
*/
export function runNpm(
args: string[],
options: { cwd?: string; timeoutMs?: number } = {}
options: { cwd?: string; timeoutMs?: number; prefix?: string } = {}
): Promise<NpmRunResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
// On Windows, npm is npm.cmd; on Unix it's npm.
@@ -86,12 +144,11 @@ export function runNpm(
execFile(
npmBin,
args,
{
buildNpmExecOptions(process.platform, {
cwd: options.cwd,
timeout: timeoutMs,
env: process.env,
maxBuffer: 10 * 1024 * 1024, // 10 MB for npm output
},
timeoutMs,
prefix: options.prefix,
}),
(err, stdout, stderr) => {
if (err) {
const classified = classifyError(