fix(cli): refuse an unsupported Node.js release before linking (#9297)

* fix(cli): refuse an unsupported Node.js release before linking

The dispatcher imported the compiled runtime before checking the runtime
range, so a release outside `engines.node` failed while linking a module
that imports `createZstdDecompress`. Callers saw a module-linking stack
trace from every command, including `--version` and `doctor`, instead of
the refusal the installer and `engines` already promise.

Check `engines.node` from the package read the dispatcher already performs,
before the first compiled import. The helper has no imports of its own: the
dispatcher must admit the runtime before any dependency can be resolved.

Fixes #9296

* fix(cli): ship and align runtime admission

* fix(ci): cover packaged runtime admission helper

* test(cli): assert Node admission with structured range results

Export satisfiesNodeRange and cover caret, minimum, union, and
non-release versions. The dispatcher proves the compiled runtime never
loads by the absence of its marker file, and compares stderr to the
helper's own message.

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-11-10 13:53:58 -05:00
1 parent b2f3dc81fa
commit dbfab4a65d
11 files changed
+224 -6

No files matched your search

+1
View File
@@ -76,6 +76,7 @@
"bridge/agentenv/solve_cleanup.py",
"scripts/rea.mjs",
"scripts/lib/cli-process-environment.mjs",
"scripts/lib/runtime-admission.mjs",
"scripts/rea-android-workspace-guardian.mjs",
"scripts/electron-active-hook.cjs",
"scripts/electron-active-hook-boundaries.cjs",
+4
View File
@@ -247,6 +247,8 @@ const PATH_SCOPES = {
"tests/boundary/filesystem/releaseProposal.test.ts",
"scripts/rea.mjs",
"scripts/lib/cli-process-environment.mjs",
"scripts/lib/runtime-admission.mjs",
"scripts/lib/runtime-admission.d.mts",
"install.sh",
"src/config/**",
"src/application/Setup*.ts",
@@ -493,6 +495,8 @@ const PATH_SCOPES = {
"src/contracts/toolEffects.ts",
"scripts/rea.mjs",
"scripts/lib/cli-process-environment.mjs",
"scripts/lib/runtime-admission.mjs",
"scripts/lib/runtime-admission.d.mts",
"src/process/**",
"src/artifacts/SafeOutputTree.ts",
"scripts/verify/javascript/recovery.mjs",
+11
View File
@@ -0,0 +1,11 @@
/** True when `version` is a release admitted by an `engines.node` range. */
export function satisfiesNodeRange(range: string, version: string): boolean;
/** Refusal text for a release outside `range`, or undefined when it is admitted. */
export function unsupportedRuntimeMessage(
range: string,
version: string,
): string | undefined;
/** Human-readable release families for an `engines.node` range. */
export function supportedReleaseProse(range: string): string;
+54
View File
@@ -0,0 +1,54 @@
/**
* Reject a Node.js release the compiled runtime cannot link against.
*
* The dispatcher imports compiled modules before it can report anything, and
* those modules import Node APIs that some releases do not export. Checking the
* published range here keeps that failure a readable refusal instead of a
* module-linking stack trace.
*
* `range` is the package's own `engines.node`, so the dispatcher and the
* installer cannot drift apart. This module deliberately has no imports: the
* dispatcher must admit the runtime before any dependency, including `semver`,
* can be resolved.
*/
export const unsupportedRuntimeMessage = (range, version) => {
if (typeof range !== "string" || range.length === 0) return undefined;
if (satisfiesNodeRange(range, version)) return undefined;
return (
`REA requires ${supportedReleaseProse(range)}; this process runs Node.js ${version}. ` +
"Install a supported release, then retry."
);
};
/** Human-readable release families for an `engines.node` range. */
export const supportedReleaseProse = (range) =>
`Node.js ${range
.split("||")
.map((clause) => {
const floor = /^(\^|>=)\s*(\d+\.\d+\.\d+)$/.exec(clause.trim());
if (floor === null) return clause.trim();
return floor[1] === ">="
? `${floor[2]}+`
: `${floor[2].split(".")[0]}.x (>=${floor[2]})`;
})
.join(", ")}`;
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
const FLOOR = /^(>=|\^)\s*(\d+)\.(\d+)\.(\d+)$/;
/** Every clause of the published range is `^major.minor.patch` or `>=`. */
export const satisfiesNodeRange = (range, version) => {
const found = VERSION.exec(version);
// A prerelease or a non-release spelling is never an admitted release.
if (found === null) return false;
const [, major, minor, patch] = found;
return range.split("||").some((clause) => {
const floor = FLOOR.exec(clause.trim());
if (floor === null) return false;
const [, operator, floorMajor, floorMinor, floorPatch] = floor;
if (Number(major) !== Number(floorMajor))
return operator === ">=" && Number(major) > Number(floorMajor);
if (minor !== floorMinor) return Number(minor) > Number(floorMinor);
return Number(patch) >= Number(floorPatch);
});
};
+1
View File
@@ -46,6 +46,7 @@ if (await pathExists(join(root, "src"))) {
} else {
for (const path of [
"scripts/lib/cli-process-environment.mjs",
"scripts/lib/runtime-admission.mjs",
"dist/main.js",
"dist/mcp/main.js",
"dist/cli-runtime/cli.js",
+14
View File
@@ -5,6 +5,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { prepareCliProcessEnvironment } from "./lib/cli-process-environment.mjs";
import { unsupportedRuntimeMessage } from "./lib/runtime-admission.mjs";
// Route production MCP before importing Incur. Incur owns registration helpers
// such as `mcp add`, while the MCP runtime serves the tool catalog over stdio
@@ -13,6 +14,19 @@ const args = process.argv.slice(2);
const { default: packageJson } = await import("../package.json", {
with: { type: "json" },
});
// Admit the runtime before the first compiled import. Compiled modules import
// Node APIs that absent releases only reject while linking, so an unsupported
// release previously surfaced as a module error from every command, including
// `--version` and `doctor`. `process.exit` is required here: setting exitCode
// would still reach the imports below.
const unsupportedRuntime = unsupportedRuntimeMessage(
packageJson.engines?.node,
process.versions.node,
);
if (unsupportedRuntime !== undefined) {
process.stderr.write(`${unsupportedRuntime}\n`);
process.exit(1);
}
prepareCliProcessEnvironment(
process.env,
process.platform,
+2
View File
@@ -191,6 +191,8 @@ export async function verifyPackagePack({ root, workspace, commands }) {
throw new Error("package did not expose both rea command entry points");
if (!packedFiles.includes("package/scripts/lib/cli-process-environment.mjs"))
throw new Error("package omitted the CLI process environment owner");
if (!packedFiles.includes("package/scripts/lib/runtime-admission.mjs"))
throw new Error("package omitted the CLI runtime admission owner");
if (packedFiles.some((path) => path.startsWith("package/.agents/")))
throw new Error(
"package included authored skill sources instead of only the generated bundle",
+71 -1
View File
@@ -1,9 +1,10 @@
import { copyFile, cp } from "node:fs/promises";
import { access, copyFile, cp } from "node:fs/promises";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect } from "vitest";
import { unsupportedRuntimeMessage } from "../../../scripts/lib/runtime-admission.mjs";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import type { TestWorkspace } from "../../support/workspace/workspaceFixture.js";
@@ -27,6 +28,10 @@ const installCliRuntime = async (
"scripts/lib/cli-process-environment.mjs",
workspace.path("scripts/lib/cli-process-environment.mjs"),
),
copyFile(
"scripts/lib/runtime-admission.mjs",
workspace.path("scripts/lib/runtime-admission.mjs"),
),
workspace.write(
"package.json",
JSON.stringify({ type: "module", version: "1.0.0" }),
@@ -188,6 +193,67 @@ describe("doctor configuration dispatch", () => {
);
});
describe("runtime admission dispatch", () => {
workspaceCliTest(
"refuses an unsupported runtime before loading the compiled runtime",
async ({ processes, workspace }) => {
const marker = workspace.path("compiled-runtime-loaded");
const loaded = `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(marker)}, "loaded");\n`;
const range = ">=999.0.0";
await installCliRuntime(workspace, loaded, loaded);
await workspace.write(
"package.json",
JSON.stringify({
type: "module",
version: "1.0.0",
engines: { node: range },
}),
);
const result = await processes.run(
process.execPath,
[workspace.path("scripts/rea.mjs"), "--version"],
{ cwd: workspace.root },
);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toBe(
`${unsupportedRuntimeMessage(range, process.versions.node)}\n`,
);
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
},
);
workspaceCliTest(
"admits a runtime the package engines field allows",
async ({ processes, workspace }) => {
await installCliRuntime(
workspace,
"export const closeCliResources = () => {}; export const createCli = () => ({ serve: () => {} });\n",
);
const previousMajor = Number(process.versions.node.split(".")[0]) - 1;
for (const range of [
`^${process.versions.node}`,
`>=${previousMajor}.0.0`,
]) {
await workspace.write(
"package.json",
JSON.stringify({
type: "module",
version: "1.0.0",
engines: { node: range },
}),
);
const result = await processes.run(
process.execPath,
[workspace.path("scripts/rea.mjs"), "fixture"],
{ cwd: workspace.root },
);
expect(result.stderr).toBe("");
}
},
);
});
describe("executable dispatcher", () => {
workspaceCliTest(
"does not append recovery output after delegated stdout has begun",
@@ -300,6 +366,10 @@ describe("executable dispatcher", () => {
"scripts/lib/cli-process-environment.mjs",
workspace.path("scripts/lib/cli-process-environment.mjs"),
),
copyFile(
"scripts/lib/runtime-admission.mjs",
workspace.path("scripts/lib/runtime-admission.mjs"),
),
workspace.write(
"package.json",
JSON.stringify({ type: "module", version: "1.0.0" }),
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { satisfiesNodeRange } from "../../../scripts/lib/runtime-admission.mjs";
describe("satisfiesNodeRange", () => {
it("admits a caret floor and later patches or minors in that major", () => {
expect(satisfiesNodeRange("^22.23.4", "22.23.4")).toBe(true);
expect(satisfiesNodeRange("^22.23.4", "22.23.5")).toBe(true);
expect(satisfiesNodeRange("^22.23.4", "22.24.0")).toBe(true);
expect(satisfiesNodeRange("^22.23.4", "22.23.3")).toBe(false);
expect(satisfiesNodeRange("^22.23.4", "22.22.9")).toBe(false);
});
it("rejects a higher major under a caret and admits it under a minimum", () => {
expect(satisfiesNodeRange("^22.23.4", "23.0.0")).toBe(false);
expect(satisfiesNodeRange(">=22.23.4", "23.0.0")).toBe(true);
expect(satisfiesNodeRange(">=22.23.4", "22.23.4")).toBe(true);
expect(satisfiesNodeRange(">=22.23.4", "22.23.3")).toBe(false);
expect(satisfiesNodeRange(">=22.23.4", "22.22.0")).toBe(false);
});
it("admits either clause of a union", () => {
const range = "^22.23.4 || >=26.12.1";
expect(satisfiesNodeRange(range, "22.23.4")).toBe(true);
expect(satisfiesNodeRange(range, "26.12.1")).toBe(true);
expect(satisfiesNodeRange(range, "27.0.0")).toBe(true);
expect(satisfiesNodeRange(range, "24.0.0")).toBe(false);
expect(satisfiesNodeRange(range, "26.12.0")).toBe(false);
});
it("rejects prerelease and non-release spellings", () => {
expect(satisfiesNodeRange("^22.23.4", "22.23.4-rc.1")).toBe(false);
expect(satisfiesNodeRange(">=22.23.4", "22.23.4-rc.1")).toBe(false);
expect(satisfiesNodeRange("^22.23.4", "not-a-version")).toBe(false);
expect(satisfiesNodeRange("^22.23.4", "22.23")).toBe(false);
});
});
@@ -1492,6 +1492,26 @@ it.each([
readiness: false,
},
],
[
"scripts/lib/runtime-admission.mjs",
{
code: true,
package: true,
runtime: true,
javascript_recovery: true,
readiness: false,
},
],
[
"scripts/lib/runtime-admission.d.mts",
{
code: true,
package: true,
runtime: true,
javascript_recovery: true,
readiness: false,
},
],
[
"tests/conformance/readiness/report/reconstructionReadiness.ts",
{
+9 -5
View File
@@ -780,11 +780,15 @@ const copyCompiledPackage = async (prefix: string): Promise<string> => {
await cp(resolve("dist"), join(directory, "dist"), { recursive: true });
await mkdir(join(directory, "scripts/lib"), { recursive: true });
await cp(resolve("scripts/rea.mjs"), join(directory, "scripts/rea.mjs"));
// The launcher imports its process-environment owner, as the package ships it.
await cp(
resolve("scripts/lib/cli-process-environment.mjs"),
join(directory, "scripts/lib/cli-process-environment.mjs"),
);
// Copy the helpers selected by the package, so this fixture exercises the
// same launcher files that an installation receives.
const manifest = JSON.parse(await readFile("package.json", "utf8")) as {
files: string[];
};
for (const path of manifest.files.filter((file) =>
file.startsWith("scripts/lib/"),
))
await cp(resolve(path), join(directory, path));
await cp(resolve("package.json"), join(directory, "package.json"));
await symlink(
resolve("node_modules"),