mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
46fad5296a
commit
666a907316
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user