diff --git a/package.json b/package.json index 2313ac1d20..1649b6c00f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 7d35e994d2..1f04ef012c 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -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", diff --git a/scripts/lib/runtime-admission.d.mts b/scripts/lib/runtime-admission.d.mts new file mode 100644 index 0000000000..76d8727d01 --- /dev/null +++ b/scripts/lib/runtime-admission.d.mts @@ -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; diff --git a/scripts/lib/runtime-admission.mjs b/scripts/lib/runtime-admission.mjs new file mode 100644 index 0000000000..5ba168383f --- /dev/null +++ b/scripts/lib/runtime-admission.mjs @@ -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); + }); +}; diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs index f3c8061927..eca4b6268f 100644 --- a/scripts/prepack.mjs +++ b/scripts/prepack.mjs @@ -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", diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 2c0ff9af02..3c57696a85 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -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, diff --git a/scripts/verify-package-pack.mjs b/scripts/verify-package-pack.mjs index 7009b1a81c..22f889874b 100644 --- a/scripts/verify-package-pack.mjs +++ b/scripts/verify-package-pack.mjs @@ -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", diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 3c9a0ff22a..af8ac83d82 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -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" }), diff --git a/tests/boundary/cli/runtimeAdmission.test.ts b/tests/boundary/cli/runtimeAdmission.test.ts new file mode 100644 index 0000000000..cf96efc3f7 --- /dev/null +++ b/tests/boundary/cli/runtimeAdmission.test.ts @@ -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); + }); +}); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 7ff6281d9b..c3b560ec47 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -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", { diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index 6d7ac54232..693c87fe94 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -780,11 +780,15 @@ const copyCompiledPackage = async (prefix: string): Promise => { 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"),