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) => {