From ece4aaecbb11fb88895d66cd258cd38eeb9f562f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Oct 2026 19:45:12 -0700 Subject: [PATCH] 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. --- docs/install/bun.md | 4 + .../preinstall-lifecycle-paths.json | 20 +++ .../preinstall-package-manager-warning.mjs | 60 +++++++-- ...preinstall-package-manager-warning.test.ts | 116 ++++++++++++++++-- 4 files changed, 176 insertions(+), 24 deletions(-) create mode 100644 scripts/e2e/lib/bun-only-runtime/preinstall-lifecycle-paths.json diff --git a/docs/install/bun.md b/docs/install/bun.md index 64d96cba9e9f..c3a71d1875e7 100644 --- a/docs/install/bun.md +++ b/docs/install/bun.md @@ -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 /openclaw.mjs doctor --fix`. diff --git a/scripts/e2e/lib/bun-only-runtime/preinstall-lifecycle-paths.json b/scripts/e2e/lib/bun-only-runtime/preinstall-lifecycle-paths.json new file mode 100644 index 000000000000..4bbb0df6dab4 --- /dev/null +++ b/scripts/e2e/lib/bun-only-runtime/preinstall-lifecycle-paths.json @@ -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"] + } +] diff --git a/scripts/preinstall-package-manager-warning.mjs b/scripts/preinstall-package-manager-warning.mjs index b9cca51c5f8e..fd7243f8ee9d 100644 --- a/scripts/preinstall-package-manager-warning.mjs +++ b/scripts/preinstall-package-manager-warning.mjs @@ -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; } diff --git a/test/scripts/preinstall-package-manager-warning.test.ts b/test/scripts/preinstall-package-manager-warning.test.ts index f0025b33ab89..251fb9531ee0 100644 --- a/test/scripts/preinstall-package-manager-warning.test.ts +++ b/test/scripts/preinstall-package-manager-warning.test.ts @@ -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[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) => {