fix: preserve working installs on unsupported Node (#106994)

* fix(update): preserve installs on unsupported Node

* fix(update): verify skipped install scripts

* refactor(update): share global install preflight

* fix(update): validate Bun-installed Node runtime

* fix(update): classify install preflight failures

* refactor(update): normalize install failure reasons

* style(update): format updater policy test

* fix(update): preserve pnpm script policy safety

* fix(update): classify hosted git sources

* chore: leave release notes to release flow

* fix(release): defer package inventory helper loading

* style(release): keep inventory loader LOC-neutral

* fix(update): validate staged package lifecycle

* fix(update): match packed lifecycle contract

* fix(update): harden Bun package activation

* test(update): model packed Bun lifecycle

* style(update): avoid lifecycle name shadowing

* fix(update): remove unsafe Bun staging experiment

* fix(update): satisfy updater type checks

* fix(update): preserve packed npm package name

* fix(update): preserve checkout failure status

* fix(update): disable scripts while packing candidates

* fix(update): preflight source package engines safely

* fix(update): preserve legacy npm downgrades

* fix(update): pin npm packing to selected Node

* fix(update): stage npm activation before replacing live package

* fix(update): guard non-npm source activation

* refactor(update): isolate npm staging helpers

* fix(update): harden staged package lifecycle

* test(update): satisfy merged type gates

* fix(update): privatize runtime npm helper

* fix(ci): satisfy merged lint and type gates

* docs(changelog): defer updater note to release

* fix(update): guard package activation runtime

* fix(update): preserve installs on unsupported Node

* test(update): reject prerelease Node runtimes

* test(update): use shared temp cleanup

* fix(update): fail closed when install scripts are skipped

* fix(update): pack install guard in docker artifacts

* fix(ci): keep install guard export tooling-local
This commit is contained in:
Peter Steinberger
2026-07-14 23:19:48 -07:00
committed by GitHub
parent bda5288365
commit 23c0a7b612
12 changed files with 456 additions and 17 deletions
+5 -2
View File
@@ -294,8 +294,11 @@ third-party packages, and non-npm sources keep their existing intent.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. npm global installs use a staged
install: OpenClaw installs the new package into a temporary npm prefix,
verifies the packaged `dist` inventory there, then swaps that clean package
tree into the real global prefix. If verification fails, post-update doctor,
lets the candidate package validate the host Node version during `preinstall`,
and verifies the packaged `dist` inventory there. A packed completion guard
stays outside that inventory until `preinstall` succeeds, so package managers
that skip lifecycle scripts also stop before activation. OpenClaw then swaps the
clean package tree into the real global prefix. If verification fails, post-update doctor,
plugin sync, and restart work do not run from the suspect tree. Even when the
installed version already matches the target, the command refreshes the
global package install, then runs plugin sync, a core-command completion
+6 -3
View File
@@ -159,9 +159,12 @@ openclaw doctor --lint --json
```
When `openclaw update` manages a global npm install, it installs the target
into a temporary npm prefix first, verifies the packaged `dist` inventory, then
swaps the clean package tree into the real global prefix — avoiding npm
overlaying a new package onto stale files from the old one. If the install
into a temporary npm prefix first. The candidate package validates the host
Node version during `preinstall`; only then does OpenClaw verify the packaged
`dist` inventory and swap the clean package tree into the real global prefix. A
packed completion guard is omitted from the expected inventory and removed only
after `preinstall` succeeds, so skipped lifecycle scripts also fail before the
swap. This avoids npm overlaying a new package onto stale files from the old one. If the install
command fails, OpenClaw retries once with `--omit=optional`, which helps hosts
where native optional dependencies cannot compile.
@@ -306,10 +306,13 @@ const entrySet = new Set(normalized);
const errors = [];
const warnings = [];
const REQUIRED_TARBALL_ENTRIES = ["dist/control-ui/index.html", ...WORKSPACE_TEMPLATE_PACK_PATHS];
const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard";
const REQUIRED_TARBALL_ENTRY_PREFIXES = ["dist/control-ui/assets/"];
const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 };
const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 };
const LEGACY_SHRINKWRAP_COMPAT_MAX = { year: 2026, month: 5, day: 20 };
// 2026.7.1 shipped before the guard existed. Historical inspection may still check it.
const LEGACY_INSTALL_GUARD_COMPAT_MAX = { year: 2026, month: 7, day: 1 };
const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS);
const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [
@@ -377,6 +380,11 @@ function isLegacyShrinkwrapCompatVersion(version) {
return parsed ? compareCalver(parsed, LEGACY_SHRINKWRAP_COMPAT_MAX) <= 0 : false;
}
function isLegacyInstallGuardCompatVersion(version) {
const parsed = parseCalver(version);
return parsed ? compareCalver(parsed, LEGACY_INSTALL_GUARD_COMPAT_MAX) <= 0 : false;
}
function readTarEntry(entryPath) {
const candidates = [
path.join(extractDir, entryPath),
@@ -442,6 +450,13 @@ if (entrySet.has("package.json")) {
if (entrySet.has("package-lock.json")) {
errors.push("package tarball must ship npm-shrinkwrap.json, not package-lock.json");
}
if (!entrySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
if (isLegacyInstallGuardCompatVersion(packageVersion)) {
warnings.push("legacy package omits the preinstall completion guard");
} else {
errors.push(`missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`);
}
}
if (!entrySet.has("npm-shrinkwrap.json")) {
if (isLegacyShrinkwrapCompatVersion(packageVersion)) {
warnings.push("legacy package omits npm-shrinkwrap.json");
@@ -511,6 +526,11 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
} else {
const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/"));
const normalizedInventorySet = new Set(normalizedInventory);
if (normalizedInventorySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) {
errors.push(
`package dist inventory must omit install guard ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`,
);
}
packageDistImports = runPhase("dist import graph", () =>
collectPackageDistImports({
files: normalized,
+33 -3
View File
@@ -10,6 +10,8 @@ import {
export { LOCAL_BUILD_METADATA_DIST_PATHS } from "./local-build-metadata-paths.mjs";
export { PACKAGE_DIST_INVENTORY_RELATIVE_PATH };
export const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard";
const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu;
function normalizeRelativePath(value: string): string {
@@ -116,10 +118,38 @@ async function assertNoLegacyPluginDependencyStagingDebris(packageRoot: string):
);
}
export async function writePackageDistInventory(packageRoot: string): Promise<string[]> {
await assertNoLegacyPluginDependencyStagingDebris(packageRoot);
const inventory = sortUniqueStrings(await collectPackageDistInventory(packageRoot));
async function writePackageDistInventoryFile(
packageRoot: string,
entries: string[],
): Promise<string[]> {
// The packed guard intentionally stays outside the inventory until preinstall removes it.
// An updater that skips lifecycle scripts rejects the staged package before activation.
const inventory = sortUniqueStrings(
entries.filter((relativePath) => relativePath !== PACKAGE_INSTALL_GUARD_RELATIVE_PATH),
);
const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH);
await writeJson(inventoryPath, inventory, { trailingNewline: true });
return inventory;
}
export async function writePackageDistInventory(packageRoot: string): Promise<string[]> {
await assertNoLegacyPluginDependencyStagingDebris(packageRoot);
return writePackageDistInventoryFile(packageRoot, await collectPackageDistInventory(packageRoot));
}
async function writePackageInstallGuardMarker(packageRoot: string): Promise<void> {
const markerPath = path.join(packageRoot, PACKAGE_INSTALL_GUARD_RELATIVE_PATH);
await fs.mkdir(path.dirname(markerPath), { recursive: true });
await fs.writeFile(markerPath, "OpenClaw package preinstall has not completed.\n", "utf8");
}
export async function writePackageDistInventoryForPublish(
packageRoot: string,
inventory?: string[],
): Promise<string[]> {
await assertNoLegacyPluginDependencyStagingDebris(packageRoot);
const entries = inventory ?? (await collectPackageDistInventory(packageRoot));
const writtenInventory = await writePackageDistInventoryFile(packageRoot, entries);
await writePackageInstallGuardMarker(packageRoot);
return writtenInventory;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
import { basename, delimiter, join } from "node:path";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { writePackageDistInventory } from "./lib/package-dist-inventory.ts";
import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventory.ts";
import { preparePackageChangelog } from "./package-changelog.mjs";
import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs";
const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu;
@@ -292,7 +292,7 @@ function runBuildSmoke(): void {
}
async function writeDistInventory(): Promise<void> {
await writePackageDistInventory(process.cwd());
await writePackageDistInventoryForPublish(process.cwd());
}
export async function preparePrepackArtifacts(env: NodeJS.ProcessEnv = process.env): Promise<void> {
@@ -1,3 +1,29 @@
export const PACKAGE_INSTALL_GUARD_RELATIVE_PATH: string;
/** Checks a Node version against the standalone package engine-range subset. */
export function nodeVersionSatisfiesPackageEngine(
version: string | null,
engine: string | null,
): boolean;
/** Reads the Node runtime contract from the package being installed. */
export function readPackageNodeEngine(packageJsonUrl?: URL): string | null;
/** Rejects installation before an unsupported runtime can replace a working release. */
export function enforceSupportedNodeRuntime(
options?: {
version?: string | null;
bunVersion?: string | null;
engine?: string | null;
execPath?: string | null;
},
reportError?: (...data: unknown[]) => void,
): boolean;
/** Removes the packed sentinel only after the runtime check succeeds. */
export function completePackageInstallGuard(
options?: {
markerUrl?: URL;
remove?: (path: URL, options: { force: boolean }) => void;
},
reportError?: (...data: unknown[]) => void,
): boolean;
/**
* Detects the package manager running the current lifecycle script.
*/
+129 -2
View File
@@ -1,4 +1,5 @@
// Warns during install lifecycle when a package manager other than pnpm is used.
// Enforces the package runtime contract, then warns for non-pnpm lifecycle installs.
import { readFileSync, rmSync } from "node:fs";
import { pathToFileURL } from "node:url";
const allowedLifecyclePackageManagers = new Set(["pnpm", "npm", "yarn", "bun"]);
@@ -6,11 +7,133 @@ const lifecyclePackageManagerLauncherAliases = new Map([
["yarnpkg", "yarn"],
["yarn-berry", "yarn"],
]);
const NODE_ENGINE_CLAUSE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)(?:\s+<\s*v?(\d+(?:\.\d+\.\d+)?))?\s*$/iu;
const NODE_VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)$/u;
export const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard";
function normalizeEnvValue(value) {
return typeof value === "string" ? value.trim() : "";
}
function parseNodeVersion(value) {
const match = NODE_VERSION_RE.exec(normalizeEnvValue(value));
if (!match) {
return null;
}
return {
major: Number.parseInt(match[1] ?? "", 10),
minor: Number.parseInt(match[2] ?? "", 10),
patch: Number.parseInt(match[3] ?? "", 10),
};
}
function isNodeVersionAtLeast(version, minimum) {
if (version.major !== minimum.major) {
return version.major > minimum.major;
}
if (version.minor !== minimum.minor) {
return version.minor > minimum.minor;
}
return version.patch >= minimum.patch;
}
/** Checks a Node version against the standalone package engine-range subset. */
export function nodeVersionSatisfiesPackageEngine(version, engine) {
const parsedVersion = parseNodeVersion(version);
const normalizedEngine = normalizeEnvValue(engine);
if (!parsedVersion || !normalizedEngine) {
return false;
}
let satisfied = false;
for (const clause of normalizedEngine.split("||")) {
const match = NODE_ENGINE_CLAUSE_RE.exec(clause);
if (!match) {
return false;
}
const minimum = parseNodeVersion(match[1]);
const upperRaw = match[2];
const upper = upperRaw
? parseNodeVersion(upperRaw.includes(".") ? upperRaw : `${upperRaw}.0.0`)
: null;
if (!minimum || (upperRaw && !upper)) {
return false;
}
if (
isNodeVersionAtLeast(parsedVersion, minimum) &&
(!upper || !isNodeVersionAtLeast(parsedVersion, upper))
) {
satisfied = true;
}
}
return satisfied;
}
/** Reads the Node runtime contract from the package being installed. */
export function readPackageNodeEngine(
packageJsonUrl = new URL("../package.json", import.meta.url),
) {
try {
const manifest = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
return normalizeEnvValue(manifest?.engines?.node) || null;
} catch {
return null;
}
}
/** Rejects installation before an unsupported runtime can replace a working release. */
export function enforceSupportedNodeRuntime(
{
version = process.versions.node ?? null,
bunVersion = process.versions.bun ?? null,
engine = readPackageNodeEngine(),
execPath = process.execPath,
} = {},
reportError = console.error,
) {
// Bun itself remains supported for dependency installation and package scripts.
if (normalizeEnvValue(bunVersion)) {
return true;
}
if (nodeVersionSatisfiesPackageEngine(version, engine)) {
return true;
}
const requirement = engine
? `this OpenClaw release requires Node ${engine}.`
: "could not read this OpenClaw release's Node requirement.";
reportError(
[
`[openclaw] error: ${requirement}`,
`[openclaw] detected Node ${version ?? "unknown"} (exec: ${execPath || "unknown"}).`,
"[openclaw] install Node: https://nodejs.org/en/download",
"[openclaw] upgrade Node, then retry the OpenClaw update.",
].join("\n"),
);
return false;
}
/** Removes the packed sentinel only after the runtime check succeeds. */
export function completePackageInstallGuard(
{
markerUrl = new URL(`../${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`, import.meta.url),
remove = rmSync,
} = {},
reportError = console.error,
) {
try {
remove(markerUrl, { force: true });
return true;
} catch (error) {
reportError(
`[openclaw] error: could not complete package preinstall: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false;
}
}
function normalizeLifecyclePackageManagerName(value) {
const normalized = normalizeEnvValue(value).toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]*$/u.test(normalized)) {
@@ -85,5 +208,9 @@ export function warnIfNonPnpmLifecycle(env = process.env, warn = console.warn) {
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
warnIfNonPnpmLifecycle();
if (enforceSupportedNodeRuntime() && completePackageInstallGuard()) {
warnIfNonPnpmLifecycle();
} else {
process.exitCode = 1;
}
}
+2 -2
View File
@@ -2,10 +2,10 @@
// Write Package Dist Inventory script supports OpenClaw repository automation.
import { pathToFileURL } from "node:url";
import { writePackageDistInventory } from "./lib/package-dist-inventory.ts";
import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventory.ts";
async function writeCurrentPackageDistInventory(): Promise<void> {
await writePackageDistInventory(process.cwd());
await writePackageDistInventoryForPublish(process.cwd());
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+24
View File
@@ -6,7 +6,9 @@ import { describe, expect, it } from "vitest";
import {
isLegacyPluginDependencyInstallStagePath,
LOCAL_BUILD_METADATA_DIST_PATHS,
PACKAGE_INSTALL_GUARD_RELATIVE_PATH,
writePackageDistInventory,
writePackageDistInventoryForPublish,
} from "../../scripts/lib/package-dist-inventory.ts";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
@@ -41,6 +43,28 @@ describe("package dist inventory", () => {
});
});
it("keeps the pending install guard outside the expected inventory", async () => {
await withTempDir({ prefix: "openclaw-dist-install-guard-" }, async (packageRoot) => {
const currentFile = path.join(packageRoot, "dist", "current.js");
await fs.mkdir(path.dirname(currentFile), { recursive: true });
await fs.writeFile(currentFile, "export {};\n", "utf8");
await expect(writePackageDistInventoryForPublish(packageRoot)).resolves.toEqual([
"dist/current.js",
]);
await expect(collectPackageDistInventory(packageRoot)).resolves.toEqual([
"dist/current.js",
PACKAGE_INSTALL_GUARD_RELATIVE_PATH,
]);
await expect(readPackageDistInventoryIfPresent(packageRoot)).resolves.toEqual([
"dist/current.js",
]);
await expect(
fs.readFile(path.join(packageRoot, PACKAGE_INSTALL_GUARD_RELATIVE_PATH), "utf8"),
).resolves.toContain("preinstall has not completed");
});
});
it("keeps npm-omitted dist artifacts out of the inventory", async () => {
await withTempDir({ prefix: "openclaw-dist-inventory-pack-" }, async (packageRoot) => {
const packagedQaChannelRuntime = path.join(
+21 -1
View File
@@ -4,7 +4,11 @@ import fs from "node:fs/promises";
import path from "node:path";
import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts";
import {
PACKAGE_INSTALL_GUARD_RELATIVE_PATH,
writePackageDistInventory,
writePackageDistInventoryForPublish,
} from "../../scripts/lib/package-dist-inventory.ts";
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js";
import { withTempDir } from "../test-helpers/temp-dir.js";
import { captureEnv } from "../test-utils/env.js";
@@ -809,6 +813,22 @@ describe("update global helpers", () => {
});
});
it("rejects a staged package when lifecycle scripts leave the install guard", async () => {
await withTempDir({ prefix: "openclaw-update-global-guard-" }, async (packageRoot) => {
await writeGlobalPackageJson(packageRoot, "2026.7.2");
for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) {
const absolutePath = path.join(packageRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, "export {};\n", "utf8");
}
await writePackageDistInventoryForPublish(packageRoot);
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
`unexpected packaged dist file ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`,
);
});
});
it("reports bundled plugin install stages during installed dist verification", async () => {
await withTempDir({ prefix: "openclaw-update-global-plugin-stage-" }, async (packageRoot) => {
await writeGlobalPackageJson(packageRoot);
@@ -13,6 +13,7 @@ import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs";
import { PACKAGE_INSTALL_GUARD_RELATIVE_PATH } from "../../scripts/lib/package-dist-inventory.ts";
import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../../scripts/lib/workspace-bootstrap-smoke.mjs";
const CHECK_SCRIPT = "scripts/check-openclaw-package-tarball.mjs";
@@ -35,6 +36,7 @@ function withTarball(
version = "0.0.0",
options: {
includeControlUi?: boolean;
includeInstallGuard?: boolean;
includeShrinkwrap?: boolean;
includeWorkspaceTemplates?: boolean;
packageJson?: Record<string, unknown>;
@@ -86,7 +88,14 @@ function withTarball(
"dist/control-ui/index.html": "<!doctype html><openclaw-app></openclaw-app>",
"dist/control-ui/assets/app.js": "console.log('ok');\n",
};
const tarFiles = { ...workspaceTemplates, ...controlUiFiles, ...files };
const installGuardFile =
options.includeInstallGuard === false
? {}
: {
[PACKAGE_INSTALL_GUARD_RELATIVE_PATH]:
"OpenClaw package preinstall has not completed.\n",
};
const tarFiles = { ...workspaceTemplates, ...controlUiFiles, ...installGuardFile, ...files };
for (const [relativePath, body] of Object.entries(tarFiles)) {
const filePath = join(packageRoot, relativePath);
mkdirSync(dirname(filePath), { recursive: true });
@@ -220,6 +229,49 @@ describe("check-openclaw-package-tarball", () => {
);
});
it("requires an install guard omitted from the dist inventory", () => {
withTarball(
["dist/index.js"],
{ "dist/index.js": "export {};\n" },
(tarball) => {
const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
`missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`,
);
},
"0.0.0",
{ includeInstallGuard: false },
);
withTarball(
["dist/index.js", PACKAGE_INSTALL_GUARD_RELATIVE_PATH],
{ "dist/index.js": "export {};\n" },
(tarball) => {
const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
`package dist inventory must omit install guard ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`,
);
},
);
withTarball(
["dist/index.js"],
{ "dist/index.js": "export {};\n" },
(tarball) => {
const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" });
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain("legacy package omits the preinstall completion guard");
},
"2026.7.1",
{ includeInstallGuard: false },
);
});
it("rejects stale deep plugin SDK declaration inventory entries", () => {
withTarball(
[FLAT_PLUGIN_SDK_DECLARATION, DEEP_PLUGIN_SDK_DECLARATION],
@@ -1,10 +1,25 @@
// Preinstall Package Manager Warning tests cover preinstall package manager warning script behavior.
import { describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PACKAGE_INSTALL_GUARD_RELATIVE_PATH } from "../../scripts/lib/package-dist-inventory.ts";
import {
completePackageInstallGuard,
createPackageManagerWarningMessage,
detectLifecyclePackageManager,
enforceSupportedNodeRuntime,
nodeVersionSatisfiesPackageEngine,
PACKAGE_INSTALL_GUARD_RELATIVE_PATH as PREINSTALL_GUARD_RELATIVE_PATH,
readPackageNodeEngine,
warnIfNonPnpmLifecycle,
} from "../../scripts/preinstall-package-manager-warning.mjs";
import { isSupportedNodeVersion } from "../../src/infra/runtime-guard.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const EXPECTED_NODE_ENGINE_RANGE = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function requireFirstWarning(warn: ReturnType<typeof vi.fn>): unknown {
const [call] = warn.mock.calls;
@@ -18,6 +33,125 @@ function requireFirstWarning(warn: ReturnType<typeof vi.fn>): unknown {
return message;
}
describe("install runtime enforcement", () => {
it("shares the packaged install guard path", () => {
expect(PREINSTALL_GUARD_RELATIVE_PATH).toBe(PACKAGE_INSTALL_GUARD_RELATIVE_PATH);
});
it("reads the canonical package engine range", () => {
expect(readPackageNodeEngine()).toBe(EXPECTED_NODE_ENGINE_RANGE);
});
it.each(["22.22.2", "22.22.3", "23.11.0", "24.14.1", "24.15.0", "25.8.1", "25.9.0", "26.0.0"])(
"matches the CLI runtime guard for Node %s",
(version) => {
expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe(
isSupportedNodeVersion(version),
);
},
);
it.each(["24.15.0-rc.1", "25.9.1-nightly.20260714", "24.15"])(
"rejects non-release Node version %s",
(version) => {
expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe(false);
},
);
it("blocks unsupported Node before package replacement", () => {
const reportError = vi.fn();
expect(
enforceSupportedNodeRuntime(
{
version: "24.14.1",
engine: EXPECTED_NODE_ENGINE_RANGE,
execPath: "/opt/node/bin/node",
},
reportError,
),
).toBe(false);
expect(reportError).toHaveBeenCalledWith(
expect.stringContaining("this OpenClaw release requires Node"),
);
expect(reportError).toHaveBeenCalledWith(expect.stringContaining("detected Node 24.14.1"));
});
it("allows supported Node without an error", () => {
const reportError = vi.fn();
expect(
enforceSupportedNodeRuntime(
{
version: "24.15.0",
engine: EXPECTED_NODE_ENGINE_RANGE,
execPath: "/opt/node/bin/node",
},
reportError,
),
).toBe(true);
expect(reportError).not.toHaveBeenCalled();
});
it("exits nonzero when the packed entrypoint sees an unsupported runtime", () => {
const root = tempDirs.make("openclaw-preinstall-", realpathSync(tmpdir()));
const scriptsDir = join(root, "scripts");
mkdirSync(scriptsDir);
const scriptPath = join(scriptsDir, "preinstall-package-manager-warning.mjs");
copyFileSync(
new URL("../../scripts/preinstall-package-manager-warning.mjs", import.meta.url),
scriptPath,
);
writeFileSync(join(root, "package.json"), JSON.stringify({ engines: { node: ">=999.0.0" } }));
const result = spawnSync(process.execPath, [scriptPath], { encoding: "utf8" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("requires Node >=999.0.0");
expect(result.stderr).toContain(`detected Node ${process.versions.node}`);
});
it("allows Bun package lifecycle scripts", () => {
const reportError = vi.fn();
expect(
enforceSupportedNodeRuntime(
{
version: "24.14.1",
bunVersion: "1.3.0",
engine: EXPECTED_NODE_ENGINE_RANGE,
execPath: "/opt/bun/bin/bun",
},
reportError,
),
).toBe(true);
expect(reportError).not.toHaveBeenCalled();
});
it("removes the install guard after runtime validation", () => {
const markerUrl = new URL("file:///tmp/openclaw-install-guard");
const remove = vi.fn();
const reportError = vi.fn();
expect(completePackageInstallGuard({ markerUrl, remove }, reportError)).toBe(true);
expect(remove).toHaveBeenCalledWith(markerUrl, { force: true });
expect(reportError).not.toHaveBeenCalled();
});
it("fails installation when the install guard cannot be removed", () => {
const reportError = vi.fn();
expect(
completePackageInstallGuard(
{
remove: () => {
throw new Error("read-only package");
},
},
reportError,
),
).toBe(false);
expect(reportError).toHaveBeenCalledWith(
expect.stringContaining("could not complete package preinstall: read-only package"),
);
});
});
describe("detectLifecyclePackageManager", () => {
it("prefers npm_config_user_agent when present", () => {
expect(