mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-31 12:37:59 +00:00
fix: restore Bun-only global installs with Bun 1.4.4 (#175990)
* fix: restore Bun-only global installs with reordered lifecycle PATH Recognize ordered lifecycle binary directories on both sides of the inherited PATH while preserving persistent Node selection and the explicit Bun launcher contract. Cover both Bun layouts and inherited ancestor-bin ordering. * fix: disambiguate Bun lifecycle layouts before probing Node Use the Bun runtime version and package root to distinguish the full-prefix and reordered dependency layouts. Preserve inherited ancestor suffixes and root lifecycles while retaining the explicit launcher and persistent Node checks. * test: pin the simulated Bun lifecycle version Keep the physical Node-probe fixture independent of the Bun version hosting Vitest.
This commit is contained in:
parent
df60ac04ed
commit
ece4aaecbb
4 changed files with 176 additions and 24 deletions
|
|
@ -111,6 +111,10 @@ openclaw --version
|
|||
openclaw status --json
|
||||
```
|
||||
|
||||
The preinstall check ignores Bun's temporary lifecycle shims and accepts its
|
||||
package-manager PATH ordering. If a persistent `node` is on PATH, it must be a
|
||||
supported Node runtime; an alias to Bun does not satisfy that check.
|
||||
|
||||
On macOS and Linux without Node, the trusted package lifecycle installs a launcher
|
||||
that uses that exact Bun executable. Updates preserve it. To repair an older or
|
||||
missing launcher, run `/absolute/path/to/bun <package-root>/openclaw.mjs doctor --fix`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
[
|
||||
{
|
||||
"name": "ancestor bins before inherited PATH",
|
||||
"bunVersion": "1.4.3",
|
||||
"env": {},
|
||||
"prefix": [
|
||||
"/work/openclaw/node_modules/.bin",
|
||||
"/work/node_modules/.bin",
|
||||
"/node_modules/.bin"
|
||||
],
|
||||
"suffix": []
|
||||
},
|
||||
{
|
||||
"name": "ancestor bins after inherited PATH",
|
||||
"bunVersion": "1.4.4",
|
||||
"env": { "npm_config_local_prefix": "/work" },
|
||||
"prefix": ["/work/openclaw/node_modules/.bin"],
|
||||
"suffix": ["/work/node_modules/.bin", "/node_modules/.bin"]
|
||||
}
|
||||
]
|
||||
|
|
@ -132,11 +132,11 @@ function isStableAbsolutePath(value, pathApi, platform) {
|
|||
return root !== "\\" && root !== "/";
|
||||
}
|
||||
|
||||
function stripBunLifecyclePathPrefix(pathEntries, cwd, pathApi, platform) {
|
||||
const expectedPrefix = [];
|
||||
function stripBunLifecyclePaths(pathEntries, cwd, pathApi, platform, dependencyLifecycle) {
|
||||
const lifecyclePaths = [];
|
||||
let directory = pathApi.resolve(cwd);
|
||||
while (true) {
|
||||
expectedPrefix.push(pathApi.join(directory, "node_modules", ".bin"));
|
||||
lifecyclePaths.push(pathApi.join(directory, "node_modules", ".bin"));
|
||||
const parent = pathApi.dirname(directory);
|
||||
if (parent === directory) {
|
||||
break;
|
||||
|
|
@ -144,18 +144,34 @@ function stripBunLifecyclePathPrefix(pathEntries, cwd, pathApi, platform) {
|
|||
directory = parent;
|
||||
}
|
||||
|
||||
if (pathEntries.length < expectedPrefix.length) {
|
||||
if (pathEntries.length < lifecyclePaths.length) {
|
||||
return null;
|
||||
}
|
||||
for (const [index, expected] of expectedPrefix.entries()) {
|
||||
const matches = (entry, expected) =>
|
||||
normalizePathForComparison(entry, pathApi, platform) ===
|
||||
normalizePathForComparison(expected, pathApi, platform);
|
||||
for (
|
||||
let prefixLength = dependencyLifecycle ? 1 : lifecyclePaths.length;
|
||||
prefixLength <= lifecyclePaths.length;
|
||||
prefixLength++
|
||||
) {
|
||||
if (
|
||||
normalizePathForComparison(pathEntries[index], pathApi, platform) !==
|
||||
normalizePathForComparison(expected, pathApi, platform)
|
||||
!lifecyclePaths
|
||||
.slice(0, prefixLength)
|
||||
.every((expected, index) => matches(pathEntries[index], expected))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const suffixStart = pathEntries.length - (lifecyclePaths.length - prefixLength);
|
||||
const suffixMatches = lifecyclePaths
|
||||
.slice(prefixLength)
|
||||
.every((expected, index) => matches(pathEntries[suffixStart + index], expected));
|
||||
if (suffixMatches) {
|
||||
// Dependency lifecycles can include private-store bins before inherited PATH.
|
||||
return pathEntries.slice(prefixLength, suffixStart);
|
||||
}
|
||||
}
|
||||
return pathEntries.slice(expectedPrefix.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,6 +183,7 @@ function stripBunLifecyclePathPrefix(pathEntries, cwd, pathApi, platform) {
|
|||
* platform?: NodeJS.Platform;
|
||||
* cwd?: string;
|
||||
* execPath?: string;
|
||||
* bunVersion?: string | null;
|
||||
* access?: (path: string, mode: number) => void;
|
||||
* realpath?: (path: string) => string;
|
||||
* run?: PackageCliNodeProbeRun;
|
||||
|
|
@ -180,6 +197,7 @@ export function probePackageCliNodeRuntime(options = {}) {
|
|||
platform = process.platform,
|
||||
cwd = process.cwd(),
|
||||
execPath = process.execPath,
|
||||
bunVersion = process.versions.bun ?? null,
|
||||
access = accessSync,
|
||||
realpath = realpathSync,
|
||||
run = spawnSync,
|
||||
|
|
@ -188,9 +206,29 @@ export function probePackageCliNodeRuntime(options = {}) {
|
|||
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);
|
||||
// Bun 1.4.4 moved dependency ancestor bins after inherited PATH. Root scripts
|
||||
// and older Bun keep the full prefix. PATH alone cannot distinguish them when
|
||||
// inherited entries also name ancestor bins; use the runtime and package root.
|
||||
const reorderedLifecycle = isNodeVersionAtLeast(parseNodeReleaseVersion(bunVersion), {
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 4,
|
||||
});
|
||||
const localPrefix = normalizeEnvValue(env.npm_config_local_prefix);
|
||||
if (reorderedLifecycle && !isStableAbsolutePath(localPrefix, pathApi, platform)) {
|
||||
return null;
|
||||
}
|
||||
const dependencyLifecycle =
|
||||
reorderedLifecycle &&
|
||||
normalizePathForComparison(localPrefix, pathApi, platform) !==
|
||||
normalizePathForComparison(pathApi.resolve(cwd), pathApi, platform);
|
||||
const pathEntries = stripBunLifecyclePaths(
|
||||
pathEnv.split(delimiter),
|
||||
cwd,
|
||||
pathApi,
|
||||
platform,
|
||||
dependencyLifecycle,
|
||||
);
|
||||
if (!pathEntries) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { copyFileSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
|||
import { dirname, join } from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import lifecyclePaths from "../../scripts/e2e/lib/bun-only-runtime/preinstall-lifecycle-paths.json" with { type: "json" };
|
||||
import {
|
||||
detectLifecyclePackageManager,
|
||||
enforceSupportedNodeRuntime,
|
||||
|
|
@ -29,6 +30,7 @@ const probe = (options: Parameters<typeof probePackageCliNodeRuntime>[0]) =>
|
|||
cwd: "/work/openclaw",
|
||||
access: () => {},
|
||||
platform: "linux",
|
||||
bunVersion: "1.4.3",
|
||||
...options,
|
||||
});
|
||||
const enforce = (
|
||||
|
|
@ -121,21 +123,34 @@ describe("install runtime enforcement", () => {
|
|||
persistent: "old-node",
|
||||
error: "detected Node 24.14.1",
|
||||
},
|
||||
{
|
||||
name: "persistent supported Node after the shim",
|
||||
launcher: "/opt/bun/bin/bun",
|
||||
bun: "1.4.2",
|
||||
persistent: "supported-node",
|
||||
accepted: true,
|
||||
error: undefined,
|
||||
},
|
||||
{
|
||||
name: "same Bun-backed node before supported Node with marker",
|
||||
launcher: "/opt/bun/bin/bun",
|
||||
bun: "1.4.2",
|
||||
persistent: "same-bun",
|
||||
},
|
||||
].map((testCase): LauncherCase =>
|
||||
Object.assign(
|
||||
{ bun: "1.4.3", accepted: false, shimMatchesBun: true, error: "detected Node missing" },
|
||||
testCase,
|
||||
),
|
||||
].flatMap((testCase) =>
|
||||
lifecyclePaths.map((layout): LauncherCase & { layout: typeof layout } => ({
|
||||
bun: "1.4.3",
|
||||
accepted: false,
|
||||
shimMatchesBun: true,
|
||||
error: "detected Node missing",
|
||||
...testCase,
|
||||
layout,
|
||||
name: `${testCase.name}, ${layout.name}`,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"enforces the explicit Bun launcher contract: $name",
|
||||
({ launcher, bun, persistent, shimMatchesBun, accepted, error }) => {
|
||||
({ launcher, bun, persistent, shimMatchesBun, accepted, error, layout }) => {
|
||||
const shimDirectory = "bun-node-ddfce5d01";
|
||||
const reportError = vi.fn();
|
||||
const run = vi.fn(
|
||||
|
|
@ -167,6 +182,7 @@ describe("install runtime enforcement", () => {
|
|||
bunVersion: "1.4.3",
|
||||
probeNodeRuntime: () =>
|
||||
probe({
|
||||
bunVersion: layout.bunVersion,
|
||||
execPath: "/opt/bun/bin/bun",
|
||||
realpath: (candidate) =>
|
||||
(candidate === `/tmp/${shimDirectory}/node` && shimMatchesBun) ||
|
||||
|
|
@ -174,15 +190,19 @@ describe("install runtime enforcement", () => {
|
|||
? "/opt/bun/bin/bun"
|
||||
: candidate,
|
||||
env: {
|
||||
...layout.env,
|
||||
OPENCLAW_PACKAGE_BUN_LAUNCHER: launcher,
|
||||
NODE_OPTIONS: "--require=fixture.cjs",
|
||||
Node_Options: "--require=other-fixture.cjs",
|
||||
},
|
||||
pathEnv: [
|
||||
...lifecyclePrefix,
|
||||
...layout.prefix,
|
||||
...(persistent === "same-bun" ? ["/opt/bun/bin", "/opt/node/bin"] : []),
|
||||
`/tmp/${shimDirectory}`,
|
||||
...(persistent === "old-node" ? ["/opt/node/bin"] : []),
|
||||
...(persistent === "old-node" || persistent === "supported-node"
|
||||
? ["/opt/node/bin"]
|
||||
: []),
|
||||
...layout.suffix,
|
||||
].join(":"),
|
||||
run,
|
||||
}),
|
||||
|
|
@ -196,7 +216,7 @@ describe("install runtime enforcement", () => {
|
|||
expect(reportError).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(run.mock.calls.map(([command]) => command)).toEqual(
|
||||
persistent === "old-node"
|
||||
persistent === "old-node" || persistent === "supported-node"
|
||||
? ["/opt/node/bin/node"]
|
||||
: persistent === "same-bun"
|
||||
? ["/opt/bun/bin/node"]
|
||||
|
|
@ -255,6 +275,7 @@ describe("install runtime enforcement", () => {
|
|||
probeNodeRuntime: () =>
|
||||
probePackageCliNodeRuntime({
|
||||
cwd,
|
||||
bunVersion: "1.4.3",
|
||||
env: { OPENCLAW_PACKAGE_BUN_LAUNCHER: launcher },
|
||||
pathEnv: [
|
||||
...prefix,
|
||||
|
|
@ -274,10 +295,17 @@ describe("install runtime enforcement", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("checks a duplicate lifecycle-looking entry inherited in the original PATH", () => {
|
||||
it.each(lifecyclePaths)("checks a duplicate inherited entry: $name", (layout) => {
|
||||
const candidates: string[] = [];
|
||||
const runtime = probe({
|
||||
pathEnv: [...lifecyclePrefix, "/work/openclaw/node_modules/.bin", "/opt/node/bin"].join(":"),
|
||||
bunVersion: layout.bunVersion,
|
||||
env: layout.env,
|
||||
pathEnv: [
|
||||
...layout.prefix,
|
||||
"/work/openclaw/node_modules/.bin",
|
||||
"/opt/node/bin",
|
||||
...layout.suffix,
|
||||
].join(":"),
|
||||
run: (command) => {
|
||||
candidates.push(command);
|
||||
return {
|
||||
|
|
@ -295,8 +323,70 @@ describe("install runtime enforcement", () => {
|
|||
expect(runtime?.version).toBe("24.14.1");
|
||||
});
|
||||
|
||||
it("fails closed when Bun's lifecycle PATH prefix cannot be proven", () => {
|
||||
expectRejectedProbe({ pathEnv: "/unproven/node_modules/.bin:/opt/node/bin" });
|
||||
it.each([["/work/node_modules/.bin"], ["/work/node_modules/.bin", "/node_modules/.bin"]])(
|
||||
"preserves inherited ancestor bins before later Node paths: %j",
|
||||
(...inherited) => {
|
||||
const run = vi.fn((command: string) => ({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ version: "24.14.1", bunVersion: null, execPath: command }),
|
||||
}));
|
||||
const runtime = probe({
|
||||
bunVersion: "1.4.4",
|
||||
env: { npm_config_local_prefix: "/work" },
|
||||
pathEnv: [
|
||||
"/work/openclaw/node_modules/.bin",
|
||||
...inherited,
|
||||
"/opt/node/bin",
|
||||
"/work/node_modules/.bin",
|
||||
"/node_modules/.bin",
|
||||
].join(":"),
|
||||
run,
|
||||
});
|
||||
expect(run.mock.calls.map(([command]) => command)).toEqual(["/work/node_modules/.bin/node"]);
|
||||
expect(
|
||||
nodeVersionSatisfiesPackageEngine(runtime?.version ?? null, EXPECTED_NODE_ENGINE_RANGE),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["1.4.3", "1.4.4"])(
|
||||
"preserves an inherited ancestor suffix in a prefix-only Bun %s lifecycle",
|
||||
(bunVersion) => {
|
||||
const run = vi.fn((command: string) => ({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
version: command === "/opt/node/bin/node" ? "24.16.0" : "24.14.1",
|
||||
bunVersion: null,
|
||||
execPath: command,
|
||||
}),
|
||||
}));
|
||||
const runtime = probe({
|
||||
bunVersion,
|
||||
env: { npm_config_local_prefix: "/work/openclaw" },
|
||||
pathEnv: [
|
||||
...lifecyclePrefix,
|
||||
"/opt/node/bin",
|
||||
"/work/node_modules/.bin",
|
||||
"/node_modules/.bin",
|
||||
].join(":"),
|
||||
run,
|
||||
});
|
||||
expect(run.mock.calls.map(([command]) => command)).toEqual(["/opt/node/bin/node"]);
|
||||
expect(runtime?.version).toBe("24.16.0");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["/unproven/node_modules/.bin", "/opt/node/bin"],
|
||||
["/work/openclaw/node_modules/.bin", "/opt/node/bin", "/node_modules/.bin"],
|
||||
[
|
||||
"/work/openclaw/node_modules/.bin",
|
||||
"/opt/node/bin",
|
||||
"/node_modules/.bin",
|
||||
"/work/node_modules/.bin",
|
||||
],
|
||||
])("fails closed when the lifecycle directories cannot be proven: %j", (...entries) => {
|
||||
expectRejectedProbe({ pathEnv: entries.join(":") });
|
||||
});
|
||||
|
||||
it.each(["."])("fails closed before a relative PATH component %j", (relativeEntry) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue