fix: preserve updates across pnpm and Bun lifecycle checks (#108090)

* fix(update): validate lifecycle runtimes

* fix(update): verify Bun lifecycle path

* fix(update): reject drive-relative Node paths

* test(update): align lifecycle argv expectations
This commit is contained in:
Peter Steinberger
2026-07-15 00:26:41 -07:00
committed by GitHub
parent 46fad5296a
commit 666a907316
7 changed files with 456 additions and 32 deletions
@@ -6,6 +6,33 @@ export function nodeVersionSatisfiesPackageEngine(
): boolean;
/** Reads the Node runtime contract from the package being installed. */
export function readPackageNodeEngine(packageJsonUrl?: URL): string | null;
export type PackageCliNodeRuntime = {
version: string | null;
bunVersion: string | null;
execPath: string | null;
};
/** Finds the real Node that will launch the installed CLI after Bun removes its lifecycle PATH. */
export function probePackageCliNodeRuntime(options?: {
env?: NodeJS.ProcessEnv;
pathEnv?: string;
platform?: NodeJS.Platform;
cwd?: string;
run?: (
command: string,
args: string[],
options: {
cwd: string;
encoding: "utf8";
env: NodeJS.ProcessEnv;
timeout: number;
windowsHide: boolean;
},
) => {
status?: number | null;
stdout?: string;
error?: NodeJS.ErrnoException;
};
}): PackageCliNodeRuntime | null;
/** Rejects installation before an unsupported runtime can replace a working release. */
export function enforceSupportedNodeRuntime(
options?: {
@@ -13,6 +40,7 @@ export function enforceSupportedNodeRuntime(
bunVersion?: string | null;
engine?: string | null;
execPath?: string | null;
probeNodeRuntime?: () => PackageCliNodeRuntime | null;
},
reportError?: (...data: unknown[]) => void,
): boolean;
+142 -7
View File
@@ -1,5 +1,7 @@
// Enforces the package runtime contract, then warns for non-pnpm lifecycle installs.
import { spawnSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs";
import { posix, win32 } from "node:path";
import { pathToFileURL } from "node:url";
const allowedLifecyclePackageManagers = new Set(["pnpm", "npm", "yarn", "bun"]);
@@ -8,7 +10,10 @@ const lifecyclePackageManagerLauncherAliases = new Map([
["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;
const NODE_VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
const NODE_RUNTIME_PROBE_SOURCE =
"process.stdout.write(JSON.stringify({version:process.versions.node??null,bunVersion:process.versions.bun??null,execPath:process.execPath??null}))";
const PACKAGE_CLI_NODE_PROBE_TIMEOUT_MS = 10_000;
export const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard";
function normalizeEnvValue(value) {
@@ -81,6 +86,136 @@ export function readPackageNodeEngine(
}
}
function parseNodeRuntimeProbeOutput(value) {
try {
const parsed = JSON.parse(normalizeEnvValue(value));
if (!parsed || typeof parsed !== "object") {
return null;
}
return {
version: normalizeEnvValue(parsed.version) || null,
bunVersion: normalizeEnvValue(parsed.bunVersion) || null,
execPath: normalizeEnvValue(parsed.execPath) || null,
};
} catch {
return null;
}
}
function normalizePathForComparison(value, pathApi, platform) {
const normalized = pathApi.normalize(value);
return platform === "win32" ? normalized.toLowerCase() : normalized;
}
function isStableAbsolutePath(value, pathApi, platform) {
if (!pathApi.isAbsolute(value)) {
return false;
}
if (platform !== "win32") {
return true;
}
const root = pathApi.parse(value).root;
return root !== "\\" && root !== "/";
}
function stripBunLifecyclePathPrefix(pathEntries, cwd, pathApi, platform) {
const expectedPrefix = [];
let directory = pathApi.resolve(cwd);
while (true) {
expectedPrefix.push(pathApi.join(directory, "node_modules", ".bin"));
const parent = pathApi.dirname(directory);
if (parent === directory) {
break;
}
directory = parent;
}
if (pathEntries.length < expectedPrefix.length) {
return null;
}
for (const [index, expected] of expectedPrefix.entries()) {
if (
normalizePathForComparison(pathEntries[index], pathApi, platform) !==
normalizePathForComparison(expected, pathApi, platform)
) {
return null;
}
}
return pathEntries.slice(expectedPrefix.length);
}
/** Finds the real Node that will launch the installed CLI after Bun removes its lifecycle PATH. */
export function probePackageCliNodeRuntime(options = {}) {
const {
env = process.env,
pathEnv = env.PATH ?? "",
platform = process.platform,
cwd = process.cwd(),
run = spawnSync,
} = options;
const pathApi = platform === "win32" ? win32 : posix;
const delimiter = platform === "win32" ? ";" : ":";
const executableName = platform === "win32" ? "node.exe" : "node";
const seen = new Set();
// Bun prepends one cwd-to-root node_modules/.bin path per ancestor before
// the original PATH. Strip only that exact prefix; anything else persists.
const pathEntries = stripBunLifecyclePathPrefix(pathEnv.split(delimiter), cwd, pathApi, platform);
if (!pathEntries) {
return null;
}
for (const entry of pathEntries) {
if (!entry || !isStableAbsolutePath(entry, pathApi, platform)) {
// Relative paths, including Windows root-relative paths, resolve against
// each future CLI invocation's cwd or drive.
// No preinstall probe can safely approve the Node they may select later.
return null;
}
const candidate = pathApi.join(entry, executableName);
if (seen.has(candidate)) {
continue;
}
seen.add(candidate);
const childEnv = { ...env };
for (const key of Object.keys(childEnv)) {
if (key.toUpperCase() === "NODE_OPTIONS") {
delete childEnv[key];
}
}
const result = run(candidate, ["-e", NODE_RUNTIME_PROBE_SOURCE], {
cwd,
encoding: "utf8",
env: childEnv,
timeout: PACKAGE_CLI_NODE_PROBE_TIMEOUT_MS,
windowsHide: true,
});
if (
result?.error?.code === "EACCES" ||
result?.error?.code === "ENOENT" ||
result?.error?.code === "ENOTDIR"
) {
continue;
}
if (result?.status !== 0) {
return null;
}
const runtime = parseNodeRuntimeProbeOutput(result.stdout);
if (!runtime) {
return null;
}
// A Bun-backed candidate from the original PATH remains first after install.
// It cannot satisfy the package's Node engine contract, so fail closed.
if (runtime.bunVersion) {
return null;
}
return runtime;
}
return null;
}
/** Rejects installation before an unsupported runtime can replace a working release. */
export function enforceSupportedNodeRuntime(
{
@@ -88,14 +223,14 @@ export function enforceSupportedNodeRuntime(
bunVersion = process.versions.bun ?? null,
engine = readPackageNodeEngine(),
execPath = process.execPath,
probeNodeRuntime = probePackageCliNodeRuntime,
} = {},
reportError = console.error,
) {
// Bun itself remains supported for dependency installation and package scripts.
if (normalizeEnvValue(bunVersion)) {
return true;
}
if (nodeVersionSatisfiesPackageEngine(version, engine)) {
const detectedRuntime = normalizeEnvValue(bunVersion)
? probeNodeRuntime()
: { version, execPath };
if (nodeVersionSatisfiesPackageEngine(detectedRuntime?.version ?? null, engine)) {
return true;
}
@@ -105,7 +240,7 @@ export function enforceSupportedNodeRuntime(
reportError(
[
`[openclaw] error: ${requirement}`,
`[openclaw] detected Node ${version ?? "unknown"} (exec: ${execPath || "unknown"}).`,
`[openclaw] detected Node ${detectedRuntime?.version ?? "missing"} (exec: ${detectedRuntime?.execPath || "unknown"}).`,
"[openclaw] install Node: https://nodejs.org/en/download",
"[openclaw] upgrade Node, then retry the OpenClaw update.",
].join("\n"),
+9 -1
View File
@@ -704,7 +704,15 @@ describe("runGlobalPackageUpdateSteps", () => {
if (name !== "global update") {
throw new Error(`unexpected step ${name}`);
}
expect(argv).toEqual(["pnpm", "add", "-g", "--global-dir", globalDir, "openclaw@2.0.0"]);
expect(argv).toEqual([
"pnpm",
"add",
"-g",
"--global-dir",
globalDir,
"--allow-build=openclaw",
"openclaw@2.0.0",
]);
await writePackageRoot(packageRoot, "2.0.0");
return {
name,
+2
View File
@@ -720,6 +720,7 @@ describe("update global helpers", () => {
"pnpm",
"add",
"-g",
"--allow-build=openclaw",
"openclaw@latest",
]);
expect(globalInstallArgs("pnpm", "github:openclaw/openclaw#release/2026.5.12")).toEqual([
@@ -733,6 +734,7 @@ describe("update global helpers", () => {
"bun",
"add",
"-g",
"--trust",
"openclaw@latest",
]);
expect(globalInstallFallbackArgs("npm", "openclaw@latest")).toEqual([
+4 -20
View File
@@ -59,6 +59,7 @@ const OPENCLAW_MAIN_PACKAGE_SPEC = "github:openclaw/openclaw#main";
const COREPACK_ENABLE_DOWNLOAD_PROMPT_DEFAULT = "0";
const NPM_GLOBAL_INSTALL_QUIET_FLAGS = ["--no-fund", "--no-audit", "--loglevel=error"] as const;
const PNPM_OPENCLAW_BUILD_ALLOWLIST_FLAG = `--allow-build=${PRIMARY_PACKAGE_NAME}`;
const BUN_OPENCLAW_TRUST_FLAG = "--trust";
const FIRST_PACKAGED_DIST_INVENTORY_VERSION = { major: 2026, minor: 4, patch: 15 };
const OMITTED_PRIVATE_QA_BUNDLED_PLUGIN_ROOTS = new Set([
"dist/extensions/qa-channel",
@@ -107,23 +108,6 @@ function isExplicitPackageInstallSpec(value: string): boolean {
);
}
function stripPrimaryPackageAlias(spec: string): string {
const normalized = normalizePackageTarget(spec);
const prefix = `${PRIMARY_PACKAGE_NAME}@`;
return normalized.toLowerCase().startsWith(prefix)
? normalized.slice(prefix.length).trim()
: normalized;
}
function isPnpmOpenClawSourceInstallSpec(spec: string): boolean {
const target = stripPrimaryPackageAlias(spec);
return (
/^github:/i.test(target) ||
/^git\+(?:ssh|https|http|file):/i.test(target) ||
/^git:/i.test(target)
);
}
/**
* Extracts a pinned installed version from package specs like `openclaw@1.2.3`.
* Moving tags, URLs, git refs, and aliases return null because they cannot be
@@ -914,7 +898,7 @@ export async function detectGlobalInstallManagerByPresence(
/**
* Builds the primary package-manager argv for a global OpenClaw install.
* npm receives quiet/freshness-bypass flags; pnpm source installs allow builds.
* npm receives quiet/freshness-bypass flags; pnpm and Bun approve OpenClaw's lifecycle.
*/
export function globalInstallArgs(
managerOrCommand: GlobalInstallManager | ResolvedGlobalInstallCommand,
@@ -929,12 +913,12 @@ export function globalInstallArgs(
"add",
"-g",
...(installPrefix ? ["--global-dir", installPrefix] : []),
...(isPnpmOpenClawSourceInstallSpec(spec) ? [PNPM_OPENCLAW_BUILD_ALLOWLIST_FLAG] : []),
PNPM_OPENCLAW_BUILD_ALLOWLIST_FLAG,
spec,
];
}
if (resolved.manager === "bun") {
return [resolved.command, "add", "-g", spec];
return [resolved.command, "add", "-g", BUN_OPENCLAW_TRUST_FLAG, spec];
}
return [
resolved.command,
+2 -2
View File
@@ -3100,7 +3100,7 @@ describe("runGatewayUpdate", () => {
const { calls, runCommand } = createGlobalInstallHarness({
pkgRoot,
installCommand: "bun add -g openclaw@latest",
installCommand: "bun add -g --trust openclaw@latest",
onInstall: async () => {
await writeGlobalPackageVersion(pkgRoot);
},
@@ -3112,7 +3112,7 @@ describe("runGatewayUpdate", () => {
expect(result.mode).toBe("bun");
expect(result.before?.version).toBe("1.0.0");
expect(result.after?.version).toBe("2.0.0");
expect(calls).toContain("bun add -g openclaw@latest");
expect(calls).toContain("bun add -g --trust openclaw@latest");
});
});
@@ -12,6 +12,7 @@ import {
enforceSupportedNodeRuntime,
nodeVersionSatisfiesPackageEngine,
PACKAGE_INSTALL_GUARD_RELATIVE_PATH as PREINSTALL_GUARD_RELATIVE_PATH,
probePackageCliNodeRuntime,
readPackageNodeEngine,
warnIfNonPnpmLifecycle,
} from "../../scripts/preinstall-package-manager-warning.mjs";
@@ -51,13 +52,19 @@ describe("install runtime enforcement", () => {
},
);
it.each(["24.15.0-rc.1", "25.9.1-nightly.20260714", "24.15"])(
it.each(["24.15.0-rc.1", "25.9.1-nightly.20260714", "24.15", "24.15.0+", "24.15.0+local..1"])(
"rejects non-release Node version %s",
(version) => {
expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe(false);
},
);
it("accepts SemVer build metadata on a supported Node release", () => {
expect(nodeVersionSatisfiesPackageEngine("24.15.0+local.1", EXPECTED_NODE_ENGINE_RANGE)).toBe(
true,
);
});
it("blocks unsupported Node before package replacement", () => {
const reportError = vi.fn();
expect(
@@ -108,7 +115,7 @@ describe("install runtime enforcement", () => {
expect(result.stderr).toContain(`detected Node ${process.versions.node}`);
});
it("allows Bun package lifecycle scripts", () => {
it("allows Bun package lifecycle scripts when the installed CLI will use supported Node", () => {
const reportError = vi.fn();
expect(
enforceSupportedNodeRuntime(
@@ -117,6 +124,11 @@ describe("install runtime enforcement", () => {
bunVersion: "1.3.0",
engine: EXPECTED_NODE_ENGINE_RANGE,
execPath: "/opt/bun/bin/bun",
probeNodeRuntime: () => ({
version: "24.15.0",
bunVersion: null,
execPath: "/opt/node/bin/node",
}),
},
reportError,
),
@@ -124,6 +136,261 @@ describe("install runtime enforcement", () => {
expect(reportError).not.toHaveBeenCalled();
});
it("blocks Bun package lifecycle scripts when the installed CLI will use old Node", () => {
const reportError = vi.fn();
expect(
enforceSupportedNodeRuntime(
{
bunVersion: "1.3.0",
engine: EXPECTED_NODE_ENGINE_RANGE,
probeNodeRuntime: () => ({
version: "24.14.1",
bunVersion: null,
execPath: "/opt/node/bin/node",
}),
},
reportError,
),
).toBe(false);
expect(reportError).toHaveBeenCalledWith(expect.stringContaining("detected Node 24.14.1"));
});
it("blocks Bun package lifecycle scripts when no real Node follows its shim", () => {
const reportError = vi.fn();
expect(
enforceSupportedNodeRuntime(
{
bunVersion: "1.3.0",
engine: EXPECTED_NODE_ENGINE_RANGE,
probeNodeRuntime: () => null,
},
reportError,
),
).toBe(false);
expect(reportError).toHaveBeenCalledWith(expect.stringContaining("detected Node missing"));
});
it("strips only Bun's cwd-to-root lifecycle PATH prefix", () => {
const candidates: string[] = [];
const runtime = probePackageCliNodeRuntime({
cwd: "/work/openclaw",
pathEnv: [
"/work/openclaw/node_modules/.bin",
"/work/node_modules/.bin",
"/node_modules/.bin",
"/opt/node/bin",
].join(":"),
platform: "linux",
run: (command) => {
candidates.push(command);
return {
status: 0,
stdout: JSON.stringify({
version: "24.15.0",
bunVersion: null,
execPath: "/opt/node/bin/node",
}),
};
},
});
expect(candidates).toEqual(["/opt/node/bin/node"]);
expect(runtime).toEqual({
version: "24.15.0",
bunVersion: null,
execPath: "/opt/node/bin/node",
});
});
it("checks an inherited node_modules/.bin entry after Bun's prefix", () => {
const candidates: string[] = [];
const runtime = probePackageCliNodeRuntime({
cwd: "/work/openclaw",
pathEnv: [
"/work/openclaw/node_modules/.bin",
"/work/node_modules/.bin",
"/node_modules/.bin",
"/opt/tools/node_modules/.bin",
"/opt/node/bin",
].join(":"),
platform: "linux",
run: (command) => {
candidates.push(command);
return {
status: 0,
stdout: JSON.stringify({
version: "24.14.1",
bunVersion: null,
execPath: command,
}),
};
},
});
expect(candidates).toEqual(["/opt/tools/node_modules/.bin/node"]);
expect(runtime?.version).toBe("24.14.1");
});
it("checks a duplicate lifecycle-looking entry inherited in the original PATH", () => {
const candidates: string[] = [];
const runtime = probePackageCliNodeRuntime({
cwd: "/work/openclaw",
pathEnv: [
"/work/openclaw/node_modules/.bin",
"/work/node_modules/.bin",
"/node_modules/.bin",
"/work/openclaw/node_modules/.bin",
"/opt/node/bin",
].join(":"),
platform: "linux",
run: (command) => {
candidates.push(command);
return {
status: 0,
stdout: JSON.stringify({
version: "24.14.1",
bunVersion: null,
execPath: command,
}),
};
},
});
expect(candidates).toEqual(["/work/openclaw/node_modules/.bin/node"]);
expect(runtime?.version).toBe("24.14.1");
});
it("fails closed when Bun's lifecycle PATH prefix cannot be proven", () => {
const run = vi.fn();
expect(
probePackageCliNodeRuntime({
cwd: "/work/openclaw",
pathEnv: ["/unproven/node_modules/.bin", "/opt/node/bin"].join(":"),
platform: "linux",
run,
}),
).toBeNull();
expect(run).not.toHaveBeenCalled();
});
it("fails closed on a Bun-backed candidate from the original PATH", () => {
const candidates: string[] = [];
expect(
probePackageCliNodeRuntime({
cwd: "/work/openclaw",
pathEnv: [
"/work/openclaw/node_modules/.bin",
"/work/node_modules/.bin",
"/node_modules/.bin",
"/opt/bun-wrapper",
"/opt/node/bin",
].join(":"),
platform: "linux",
run: (command) => {
candidates.push(command);
return {
status: 0,
stdout: JSON.stringify({
version: "24.15.0",
bunVersion: "1.3.14",
execPath: "/opt/bun/bin/bun",
}),
};
},
}),
).toBeNull();
expect(candidates).toEqual(["/opt/bun-wrapper/node"]);
});
it.each(["", ".", "relative/bin"])(
"fails closed before a relative PATH component %j",
(relativeEntry) => {
const run = vi.fn();
expect(
probePackageCliNodeRuntime({
cwd: "/work/openclaw",
pathEnv: [
"/work/openclaw/node_modules/.bin",
"/work/node_modules/.bin",
"/node_modules/.bin",
relativeEntry,
"/opt/node/bin",
].join(":"),
platform: "linux",
run,
}),
).toBeNull();
expect(run).not.toHaveBeenCalled();
},
);
it.each(["\\tools", "/tools"])(
"fails closed before a Windows root-relative PATH component %j",
(relativeEntry) => {
const run = vi.fn();
expect(
probePackageCliNodeRuntime({
cwd: "C:\\work\\openclaw",
pathEnv: [
"C:\\work\\openclaw\\node_modules\\.bin",
"C:\\work\\node_modules\\.bin",
"C:\\node_modules\\.bin",
relativeEntry,
"C:\\node",
].join(";"),
platform: "win32",
run,
}),
).toBeNull();
expect(run).not.toHaveBeenCalled();
},
);
it("removes NODE_OPTIONS case-insensitively from a Windows probe", () => {
let childEnv: NodeJS.ProcessEnv | undefined;
expect(
probePackageCliNodeRuntime({
cwd: "C:\\work\\openclaw",
env: {
PATH: [
"C:\\work\\openclaw\\node_modules\\.bin",
"C:\\work\\node_modules\\.bin",
"C:\\node_modules\\.bin",
"C:\\node",
].join(";"),
NODE_OPTIONS: "--require=first.cjs",
Node_Options: "--require=second.cjs",
OPENCLAW_PROBE_SENTINEL: "preserved",
},
platform: "win32",
run: (_command, _args, options) => {
childEnv = options.env;
return {
status: 0,
stdout: JSON.stringify({
version: "24.15.0",
bunVersion: null,
execPath: "C:\\node\\node.exe",
}),
};
},
}),
).toEqual({
version: "24.15.0",
bunVersion: null,
execPath: "C:\\node\\node.exe",
});
expect(childEnv).toEqual({
PATH: [
"C:\\work\\openclaw\\node_modules\\.bin",
"C:\\work\\node_modules\\.bin",
"C:\\node_modules\\.bin",
"C:\\node",
].join(";"),
OPENCLAW_PROBE_SENTINEL: "preserved",
});
});
it("removes the install guard after runtime validation", () => {
const markerUrl = new URL("file:///tmp/openclaw-install-guard");
const remove = vi.fn();