diff --git a/scripts/preinstall-package-manager-warning.d.mts b/scripts/preinstall-package-manager-warning.d.mts index a0846de5e977..22c0dea88d22 100644 --- a/scripts/preinstall-package-manager-warning.d.mts +++ b/scripts/preinstall-package-manager-warning.d.mts @@ -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; diff --git a/scripts/preinstall-package-manager-warning.mjs b/scripts/preinstall-package-manager-warning.mjs index 3f9ca2f321f7..3b6996c20d76 100644 --- a/scripts/preinstall-package-manager-warning.mjs +++ b/scripts/preinstall-package-manager-warning.mjs @@ -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"), diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index ca522a1902af..b8f30ba94f0a 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -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, diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 5db454205c75..0033c018e47b 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -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([ diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 8ea80ae5a472..d9c4612ae407 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -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, diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index a2dbb64a7c0f..3c6aa85e4e0c 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -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"); }); }); diff --git a/test/scripts/preinstall-package-manager-warning.test.ts b/test/scripts/preinstall-package-manager-warning.test.ts index 240e8879b18a..360da07bad37 100644 --- a/test/scripts/preinstall-package-manager-warning.test.ts +++ b/test/scripts/preinstall-package-manager-warning.test.ts @@ -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();