From c4228dc7eeab585d4fb1c1cd4a07ea4f16a3bad0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Oct 2026 12:53:07 -0700 Subject: [PATCH] fix(cli): openclaw command fails to start on Bun-only installs without Node (#162541) Fix plain openclaw startup on trusted POSIX Bun-only global installs by pinning the installed Bun executable. Keep paths as inert launcher data so released updaters can relocate them safely, with existing ownership, Doctor consent, and rollback handling. Validated 114 focused tests per runtime, package/tarball integrity, native published-9.7 plain/apostrophe upgrades, and byte-exact rollback controls. Document the inherited 9.7 readiness wait and the 9.6 upgrade limitation. CI exception: the only failing test job reproduces an inherited browser mock-cache defect already fixed on main by #162796; all other selected checks passed. Exact-head ClawSweeper found no actionable code issue. --- docs/install/bun-compatibility.md | 51 ++- docs/install/bun.md | 22 + docs/install/uninstall.md | 16 +- package.json | 2 + scripts/e2e/lib/bun-only-runtime/harness.mjs | 9 +- scripts/lib/bun-cli-launcher.d.mts | 16 + scripts/lib/bun-cli-launcher.mjs | 188 ++++++++ scripts/postinstall-bun-cli-launcher.mjs | 46 ++ scripts/postinstall-bundled-plugins.mjs | 18 +- src/commands/doctor-bun-cli-launcher.test.ts | 235 ++++++++++ src/commands/doctor-bun-cli-launcher.ts | 105 +++++ src/flows/doctor-health.ts | 2 + src/infra/update-global.ts | 1 + src/infra/update-native-package-stage.test.ts | 31 +- src/infra/update-runtime-relocation.ts | 13 + test/scripts/bun-cli-launcher.test.ts | 418 ++++++++++++++++++ 16 files changed, 1154 insertions(+), 19 deletions(-) create mode 100644 scripts/lib/bun-cli-launcher.d.mts create mode 100644 scripts/lib/bun-cli-launcher.mjs create mode 100644 scripts/postinstall-bun-cli-launcher.mjs create mode 100644 src/commands/doctor-bun-cli-launcher.test.ts create mode 100644 src/commands/doctor-bun-cli-launcher.ts create mode 100644 test/scripts/bun-cli-launcher.test.ts diff --git a/docs/install/bun-compatibility.md b/docs/install/bun-compatibility.md index ffc928a1b20e..a71f575249f1 100644 --- a/docs/install/bun-compatibility.md +++ b/docs/install/bun-compatibility.md @@ -96,10 +96,18 @@ The browser plugin starts its helper processes with the Bun executable that runs ## Bun-only installs -Pin the Gateway service to your Bun executable so updates and Doctor retain it. Without Node, the `openclaw` launcher cannot start, so run the package entry point with Bun: +Trusted Bun-only global installs on macOS and Linux install an `openclaw` shell +launcher in Bun's existing global bin directory (`bun pm bin -g`). It records the +absolute Bun executable from `OPENCLAW_PACKAGE_BUN_LAUNCHER` and the installed +package entry point, so `openclaw --version`, `openclaw status --json`, and Gateway +commands work without Node. Add that bin directory to PATH. The npm package's +Node shebang and Node installs remain unchanged; Windows Bun launchers are not +supported yet. + +Pin the Gateway service to your Bun executable so updates and Doctor retain it: ```sh - /openclaw.mjs gateway install --runtime bun --runtime-path --force +openclaw gateway install --runtime bun --runtime-path --force ``` Update, repair, and Doctor maintenance children use the running Bun executable. @@ -126,6 +134,45 @@ installation. First installs and updater staging without a persistent Node require `OPENCLAW_PACKAGE_BUN_LAUNCHER` set to the absolute Bun executable that launches the CLI. The updater sets it automatically when running under Bun; an app must set it for its first `bun add -g --trust openclaw@`. Preinstall validates that launcher as Bun 1.4+ without spawning absent or nonexecutable Node candidates. Without the marker, preinstall still requires a persistent Node; a Node found on PATH must satisfy the package's Node requirements even when the marker is set. +The trusted package lifecycle creates the launcher only when Bun's existing global +bin points to that package and no persistent Node is present. Updates create it in +the private staging bin, then relocate and publish it with the package; rollback +restores the previous launcher. Reinstalling the package refreshes the recorded +Bun path. A lifecycle warning does not abort an otherwise usable package update. + +Launcher paths are literal data: spaces, apostrophes, double quotes, dollar signs, +backticks, backslashes, and globs work in both staged and final installation paths. +Released updaters can relocate the raw path bytes without turning them into shell +code. The launcher uses shell builtins to read its own data lines, then `exec` to +preserve arguments, stdin, exit status, and signals without a wrapper process, +subprocess, or temporary file. + +The renderer requires absolute paths without NUL, newline, or carriage return. +For unsupported paths, installation leaves Bun's original symlink unchanged and +Doctor reports the reason without offering launcher repair. Invoke +` /openclaw.mjs` directly, with shell quoting as needed, or use +single-line paths. If a released updater introduces a newline into a final path, +the split data is never executed: the launcher exits 127 with a target-not-found +message. Carriage-return paths remain unsupported even if a released updater +inserts one; the strict launcher parser does not adopt that modified launcher. + +If the launcher is missing, relinked, or still names a moved Bun executable, run +` /openclaw.mjs doctor --fix` from that installation. Doctor +reports the problem and uses its existing repair consent rules. It preserves +commands belonging to another installation. Custom Bun global-bin settings must +be available to the installing process and Doctor; a one-off `bun --config` +argument is not inherited by package lifecycle children. `bunx --bun openclaw` +selects Bun for that invocation only, not for the plain shell command. + +`install-cli.sh` still provisions Node and uses its existing npm or Git install +path. The macOS app owns its own launcher separately. It can reuse this POSIX +data-line launcher contract, executable mode, and atomic publication at its +existing CLI location. The app must regenerate the launcher when its runtime or +package root changes. + +Bun's uninstall cleanup removes dangling symlinks but can leave a generated shell +launcher behind; see [Remove the CLI](/install/uninstall#remove-the-cli). + Published updaters through 2026.9.6 cannot update a Bun-only install. They do not set this marker, so the new package's preinstall stops staging (`global-install-failed`). If the caller sets the marker, their own bare `node` probe fails to start instead (`update-executor-settlement-failed`). Both refusals happen before the Gateway stops, and it keeps running. A fixed version must drive the update; installing a fixed candidate cannot change the updater already running. The installed updater runs first. In a Linux split-root fixture, published diff --git a/docs/install/bun.md b/docs/install/bun.md index 75a69644a281..139c7eb607f5 100644 --- a/docs/install/bun.md +++ b/docs/install/bun.md @@ -51,6 +51,28 @@ Bun remains usable as an optional package-script runner. The default package man +## Bun-only global install + +With a supported [OpenClaw Bun fork](/install/bun-compatibility) executable: + +```sh +OPENCLAW_PACKAGE_BUN_LAUNCHER=/absolute/path/to/bun /absolute/path/to/bun add -g --trust openclaw +export PATH="$(/absolute/path/to/bun pm bin -g):$PATH" +openclaw --version +openclaw status --json +``` + +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`. +Paths with spaces, quotes, dollar signs, backticks, backslashes, and globs remain +literal. Paths containing newlines or carriage returns require explicit Bun +invocation instead of a generated launcher. +See [Bun-only installs](/install/bun-compatibility#bun-only-installs) for update, +rollback, and custom-bin behavior. + +Known limitation: the published 2026.9.7 updater can wait for its full Gateway readiness timeout after a failed update rolls back, even with `--no-restart`; once it exits, run `openclaw doctor` with Bun as shown above or follow the printed recovery command before retrying. + ## Lifecycle scripts Bun blocks dependency lifecycle scripts unless explicitly trusted. For this repo, the commonly blocked scripts are not required: diff --git a/docs/install/uninstall.md b/docs/install/uninstall.md index 75539cd8bffb..678021debd9a 100644 --- a/docs/install/uninstall.md +++ b/docs/install/uninstall.md @@ -138,14 +138,14 @@ If you used a profile, delete the matching task name and the `gateway.cmd` / Remove the Gateway service **before** deleting a checkout, launcher, or prefix. Inspect the resolved command and its target first; if ownership is unclear, leave it in place. Check [Installer internals](/install/installer) for custom checkout and prefix options. -| Installation method | CLI owner and removal | -| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Global npm (`install.sh` / `install.ps1` default) | Run `npm rm -g openclaw` with the npm/prefix that owns this install. | -| Global pnpm or Bun | Run only the matching command: `pnpm remove -g openclaw` or `bun remove -g openclaw`. | -| `install.sh --install-method git` | Inspect `~/.local/bin/openclaw`; remove that launcher only if it points to the intended checkout, then remove that checkout. | -| `install.ps1 -InstallMethod git` | Inspect `%USERPROFILE%\.local\bin\openclaw.cmd`; remove that launcher only if it points to the intended checkout, then remove that checkout. | -| `install-cli.sh` (npm or Git) | Inspect `/bin/openclaw`. The prefix defaults to `~/.openclaw`; `--prefix` / `OPENCLAW_PREFIX` overrides it. Remove a dedicated prefix only after preserving data; Git mode also needs its separate checkout removed. | -| Direct source checkout | Remove only your own wrapper/symlink, then the checkout. Keep shims owned by other installations. | +| Installation method | CLI owner and removal | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Global npm (`install.sh` / `install.ps1` default) | Run `npm rm -g openclaw` with the npm/prefix that owns this install. | +| Global pnpm or Bun | Run only the matching command: `pnpm remove -g openclaw` or `bun remove -g openclaw`. For a Bun-only shell launcher, first inspect `$(bun pm bin -g)/openclaw` for its `OpenClaw Bun launcher` header and intended package path, then remove that launcher after removing the package; Bun can leave regular launcher files behind. | +| `install.sh --install-method git` | Inspect `~/.local/bin/openclaw`; remove that launcher only if it points to the intended checkout, then remove that checkout. | +| `install.ps1 -InstallMethod git` | Inspect `%USERPROFILE%\.local\bin\openclaw.cmd`; remove that launcher only if it points to the intended checkout, then remove that checkout. | +| `install-cli.sh` (npm or Git) | Inspect `/bin/openclaw`. The prefix defaults to `~/.openclaw`; `--prefix` / `OPENCLAW_PREFIX` overrides it. Remove a dedicated prefix only after preserving data; Git mode also needs its separate checkout removed. | +| Direct source checkout | Remove only your own wrapper/symlink, then the checkout. Keep shims owned by other installations. | Git checkouts default to `~/openclaw` (`%USERPROFILE%\openclaw` on Windows); use the actual target of the launcher, including custom `--git-dir` / `-GitDir` or `OPENCLAW_GIT_DIR`. On POSIX, `OPENCLAW_HOME` can change the default checkout. Remove state/workspaces only as selected above. diff --git a/package.json b/package.json index 306a0c3501d7..febb4a396f9f 100644 --- a/package.json +++ b/package.json @@ -412,6 +412,7 @@ "scripts/lib/freebsd-service-discovery.d.mts", "scripts/lib/freebsd-service-discovery.mjs", "scripts/lib/fs-safe-prebuild.mjs", + "scripts/lib/bun-cli-launcher.mjs", "scripts/lib/javascript-statements.mjs", "scripts/install-cli.sh", "scripts/install.ps1", @@ -426,6 +427,7 @@ "scripts/lib/package-lifecycle-marker.mjs", "scripts/lib/recommended-tool-installs.json", "scripts/postinstall-bundled-plugins.mjs", + "scripts/postinstall-bun-cli-launcher.mjs", "scripts/windows-cmd-helpers.mjs", "!dist/plugin-sdk/model-catalog-pricing.d.ts", "!dist/plugin-sdk/blob-runtime.d.ts", diff --git a/scripts/e2e/lib/bun-only-runtime/harness.mjs b/scripts/e2e/lib/bun-only-runtime/harness.mjs index 204bfa55c9f3..8112ba1f714e 100644 --- a/scripts/e2e/lib/bun-only-runtime/harness.mjs +++ b/scripts/e2e/lib/bun-only-runtime/harness.mjs @@ -97,7 +97,8 @@ function start(label, args, childEnv = env) { const err = fs.openSync(stderrPath, "w"); let child; try { - child = spawn(bun, args, { + const throughLauncher = entry !== undefined && args[0] === entry; + child = spawn(throughLauncher ? "openclaw" : bun, throughLauncher ? args.slice(1) : args, { env: childEnv, cwd: env.HOME, detached: true, @@ -305,7 +306,11 @@ try { }, ); assert.equal(result.exitCode, 0, result.output.slice(-6000)); - entry = fs.realpathSync(path.join(install, "bin/openclaw")); + entry = path.join(install, "install/global/node_modules/openclaw/openclaw.mjs"); + const launcher = path.join(install, "bin/openclaw"); + assert(!fs.lstatSync(launcher).isSymbolicLink(), "Bun-only install must publish a launcher"); + assert(read(launcher).startsWith("#!/bin/sh\n# OpenClaw Bun launcher\n")); + env.PATH = `${path.dirname(launcher)}:${env.PATH}`; env.BUN_INSTALL = install; assert( !fs.existsSync(path.join(path.dirname(entry), ".openclaw-lifecycle-pending")), diff --git a/scripts/lib/bun-cli-launcher.d.mts b/scripts/lib/bun-cli-launcher.d.mts new file mode 100644 index 000000000000..f8b196eaf7c4 --- /dev/null +++ b/scripts/lib/bun-cli-launcher.d.mts @@ -0,0 +1,16 @@ +export type BunCliLauncherTarget = { bunPath: string; entryPath: string }; +export type BunCliLauncherParams = { packageRoot: string; bunPath: string; binDir: string }; +export type BunCliLauncherInspection = { + path: string; + state: "current" | "missing" | "stale" | "conflict"; +}; +export function renderBunCliLauncher(target: BunCliLauncherTarget): string; +export function getBunCliLauncherPathIssue(target: BunCliLauncherTarget): string | null; +export function parseBunCliLauncher(content: string): BunCliLauncherTarget | null; +export function resolveBunGlobalBinDir(params: { + bunPath: string; + env?: NodeJS.ProcessEnv; + cwd?: string; +}): string; +export function inspectBunCliLauncher(params: BunCliLauncherParams): BunCliLauncherInspection; +export function installBunCliLauncher(params: BunCliLauncherParams): BunCliLauncherInspection; diff --git a/scripts/lib/bun-cli-launcher.mjs b/scripts/lib/bun-cli-launcher.mjs new file mode 100644 index 000000000000..c36d6df39032 --- /dev/null +++ b/scripts/lib/bun-cli-launcher.mjs @@ -0,0 +1,188 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { isAbsolute, join, resolve } from "node:path"; + +const HEADER = "#!/bin/sh\n# OpenClaw Bun launcher\n"; +const BODY = `bun= entry= records=0 malformed= +while IFS= read -r line; do + case $line in + '#openclaw-bun='*) + [ "$records" -eq 0 ] || malformed=1 + bun=\${line#'#openclaw-bun='} + records=1 ;; + '#openclaw-entry='*) + [ "$records" -eq 1 ] || malformed=1 + entry=\${line#'#openclaw-entry='} + records=2 ;; + *) [ "$records" -eq 0 ] || malformed=1 ;; + esac +done < "$0" +if [ "$records" -ne 2 ] || [ -n "$malformed" ] || [ ! -x "$bun" ] || [ ! -f "$entry" ]; then + printf 'openclaw: Bun launcher target not found (runtime: %s, entry: %s). Run "openclaw doctor" with Bun to repair.\\n' "$bun" "$entry" >&2 + exit 127 +fi +exec "$bun" "$entry" "$@" +exit 127 +`; + +/** @param {{bunPath: string, entryPath: string}} target @returns {string | null} */ +export function getBunCliLauncherPathIssue(target) { + const unsupported = [ + ["\n", "a newline"], + ["\r", "a carriage return"], + ]; + for (const [label, value] of [ + ["Bun executable path", target.bunPath], + ["Install path", target.entryPath], + ]) { + if (!isAbsolute(value) || value.includes("\0")) { + return "Bun CLI launcher requires absolute, single-line runtime and entry paths"; + } + for (const [character, name] of unsupported) { + if (value.includes(character)) { + return `${label} contains ${name}, which cannot be stored in a launcher data line`; + } + } + } + return null; +} + +/** @param {{bunPath: string, entryPath: string}} target */ +export function renderBunCliLauncher(target) { + const issue = getBunCliLauncherPathIssue(target); + if (issue) { + throw new Error(issue); + } + // Released updaters rewrite raw prefixes, including an unknown final basename. + // Only inert data follows the terminal exit; the shell never parses paths as code. + return `${HEADER}${BODY}#openclaw-bun=${target.bunPath}\n#openclaw-entry=${target.entryPath}\n`; +} + +/** @param {string} content */ +export function parseBunCliLauncher(content) { + if (!content.startsWith(HEADER)) { + return null; + } + try { + const match = /\n#openclaw-bun=([^\r\n]*)\n#openclaw-entry=([^\r\n]*)\n$/u.exec(content); + if (!match) { + return null; + } + const target = { bunPath: match[1], entryPath: match[2] }; + return renderBunCliLauncher(target) === content ? target : null; + } catch { + return null; + } +} + +/** @param {{bunPath: string, env?: NodeJS.ProcessEnv, cwd?: string}} params */ +export function resolveBunGlobalBinDir(params) { + if (!isAbsolute(params.bunPath)) { + throw new Error("Bun CLI launcher requires an absolute Bun executable"); + } + const result = spawnSync(params.bunPath, ["pm", "bin", "-g"], { + cwd: params.cwd, + env: params.env ?? process.env, + encoding: "utf8", + timeout: 10_000, + stdio: ["ignore", "pipe", "pipe"], + }); + const binDir = result.stdout?.trim(); + if ( + result.error || + result.status !== 0 || + !binDir || + !isAbsolute(binDir) || + /[\r\n]/u.test(binDir) + ) { + throw new Error("Could not resolve the owning Bun global bin directory with bun pm bin -g"); + } + return binDir; +} + +/** @param {string} left @param {string} right */ +function samePath(left, right) { + if (resolve(left) === resolve(right)) { + return true; + } + try { + return realpathSync(left) === realpathSync(right); + } catch { + return false; + } +} + +/** @param {{packageRoot: string, bunPath: string, binDir: string}} params */ +export function inspectBunCliLauncher(params) { + const target = { bunPath: params.bunPath, entryPath: join(params.packageRoot, "openclaw.mjs") }; + const content = renderBunCliLauncher(target); + const path = join(params.binDir, "openclaw"); + let stat; + try { + stat = lstatSync(path); + } catch (error) { + if (error.code === "ENOENT") { + return { path, state: "missing" }; + } + throw error; + } + if (stat.isSymbolicLink()) { + return { path, state: samePath(path, target.entryPath) ? "stale" : "conflict" }; + } + if (!stat.isFile()) { + return { path, state: "conflict" }; + } + const existing = readFileSync(path, "utf8"); + const recorded = parseBunCliLauncher(existing); + if (!recorded || !samePath(recorded.entryPath, target.entryPath)) { + return { path, state: "conflict" }; + } + return { + path, + state: existing === content && (stat.mode & 0o111) === 0o111 ? "current" : "stale", + }; +} + +/** Caller owns the selected package/bin pair; never follows the bin symlink when writing. + * @param {{packageRoot: string, bunPath: string, binDir: string}} params + */ +export function installBunCliLauncher(params) { + const inspected = inspectBunCliLauncher(params); + if (inspected.state === "conflict") { + throw new Error( + `Bun CLI launcher ${inspected.path} belongs to another command; left unchanged`, + ); + } + if (inspected.state === "current") { + return inspected; + } + const content = renderBunCliLauncher({ + bunPath: params.bunPath, + entryPath: join(params.packageRoot, "openclaw.mjs"), + }); + mkdirSync(params.binDir, { recursive: true }); + const temporary = mkdtempSync(join(params.binDir, ".openclaw-launcher-")); + try { + const file = join(temporary, "openclaw"); + writeFileSync(file, content, { mode: 0o755, flag: "wx" }); + chmodSync(file, 0o755); + // Recheck ownership after preparing the replacement, including Doctor consent. + if (inspectBunCliLauncher(params).state === "conflict") { + throw new Error(`Bun CLI launcher ${inspected.path} changed; left unchanged`); + } + renameSync(file, inspected.path); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + return { path: inspected.path, state: "current" }; +} diff --git a/scripts/postinstall-bun-cli-launcher.mjs b/scripts/postinstall-bun-cli-launcher.mjs new file mode 100644 index 000000000000..de545e9c6000 --- /dev/null +++ b/scripts/postinstall-bun-cli-launcher.mjs @@ -0,0 +1,46 @@ +import { isAbsolute, join } from "node:path"; +import { + getBunCliLauncherPathIssue, + inspectBunCliLauncher, + installBunCliLauncher, + resolveBunGlobalBinDir, +} from "./lib/bun-cli-launcher.mjs"; +import { + detectLifecyclePackageManager, + probePackageCliNodeRuntime, +} from "./preinstall-package-manager-warning.mjs"; + +/** Complete only the Bun global link that this lifecycle already owns. + * @param {{packageRoot: string, env?: NodeJS.ProcessEnv, bunVersion?: string}} params + */ +export function installPackageBunCliLauncher(params) { + const env = params.env ?? process.env; + const bunPath = env.OPENCLAW_PACKAGE_BUN_LAUNCHER?.trim(); + if ( + process.platform === "win32" || + !(params.bunVersion ?? process.versions.bun) || + detectLifecyclePackageManager(env) !== "bun" || + !bunPath || + !isAbsolute(bunPath) + ) { + return; + } + // Reuse preinstall's persistent-Node check; a Bun install with Node keeps its bin. + if (!probePackageCliNodeRuntime({ env, cwd: params.packageRoot })?.bunVersion) { + return; + } + if ( + getBunCliLauncherPathIssue({ bunPath, entryPath: join(params.packageRoot, "openclaw.mjs") }) + ) { + return; + } + const binDir = resolveBunGlobalBinDir({ bunPath, env, cwd: params.packageRoot }); + const target = { packageRoot: params.packageRoot, bunPath, binDir }; + const inspected = inspectBunCliLauncher(target); + // A global symlink to this package is proof of ownership. Local dependency + // installs and non-inherited --config paths cannot claim an absent global bin. + if (inspected.state === "missing" || inspected.state === "conflict") { + return; + } + installBunCliLauncher(target); +} diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 95ee8a108e82..01c7f5182d68 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Package lifecycle cleanup and completion touch only this installed package. +// Package lifecycle cleanup touches this package and its verified Bun global bin. // Doctor owns operator-state migration and genuinely dangling runtime-link repair; // shared caches outside this package can still serve other installs or profiles. import { @@ -408,6 +408,22 @@ export function completePackageLifecycle(params = {}, reportError = console.erro if (isDirectPostinstallInvocation()) { runBundledPluginPostinstall(); + if ( + process.versions.bun && + process.env.OPENCLAW_PACKAGE_BUN_LAUNCHER && + !isSourceCheckoutRoot({ packageRoot: DEFAULT_PACKAGE_ROOT }) + ) { + try { + const { installPackageBunCliLauncher } = await import( + pathToFileURL(join(DEFAULT_PACKAGE_ROOT, "scripts/postinstall-bun-cli-launcher.mjs")).href + ); + installPackageBunCliLauncher({ packageRoot: DEFAULT_PACKAGE_ROOT }); + } catch (error) { + console.warn( + `[postinstall] Bun CLI launcher repair deferred: ${String(error)}. Run Bun with this package's openclaw.mjs doctor --fix.`, + ); + } + } let admitted = true; if ( process.platform === "win32" && diff --git a/src/commands/doctor-bun-cli-launcher.test.ts b/src/commands/doctor-bun-cli-launcher.test.ts new file mode 100644 index 000000000000..b66449bccb55 --- /dev/null +++ b/src/commands/doctor-bun-cli-launcher.test.ts @@ -0,0 +1,235 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { note } from "../../packages/terminal-core/src/note.js"; +import { + inspectBunCliLauncher, + installBunCliLauncher, + resolveBunGlobalBinDir, +} from "../../scripts/lib/bun-cli-launcher.mjs"; +import { noteBunCliLauncherIssues } from "./doctor-bun-cli-launcher.js"; +import { createDoctorPrompter } from "./doctor-prompter.js"; +import { resolveDoctorRepairMode } from "./doctor-repair-mode.js"; + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ note: vi.fn() })); +vi.mock("./onboard-helpers.js", () => ({ guardCancel: vi.fn() })); +vi.mock("../../scripts/lib/bun-cli-launcher.mjs", async (importOriginal) => ({ + ...(await importOriginal()), + inspectBunCliLauncher: vi.fn(), + installBunCliLauncher: vi.fn(), + resolveBunGlobalBinDir: vi.fn(), +})); + +const fixtureRoot = path.resolve("/fixture"); +const ownerInstall = path.join(fixtureRoot, "owner"); +const globalProject = path.join(ownerInstall, "install", "global"); +const root = path.join(globalProject, "node_modules", "openclaw"); +const bunPath = path.join(fixtureRoot, "runtime", "bun"); +const binDir = path.join(fixtureRoot, "custom-bin"); +const launcherPath = path.join(binDir, "openclaw"); +const fileStat = fs.statSync(new URL(import.meta.url)); + +function stubRuntime(bun = true, platform: NodeJS.Platform = "linux", executable = bunPath) { + vi.stubGlobal( + "process", + Object.create(process, { + versions: { value: { ...process.versions, bun: bun ? "1.4.3" : undefined } }, + platform: { value: platform }, + execPath: { value: executable }, + }), + ); +} + +function prompter(approved = true) { + return { + confirmAutoFix: vi.fn(async () => approved), + repairMode: resolveDoctorRepairMode({ nonInteractive: true }), + }; +} + +describe("Bun-only Doctor CLI launcher repair", () => { + beforeEach(() => { + vi.resetAllMocks(); + stubRuntime(); + vi.stubEnv("PATH", path.join(fixtureRoot, "empty-bin")); + vi.stubEnv("BUN_INSTALL", path.join(fixtureRoot, "unrelated-bun")); + vi.stubEnv("BUN_INSTALL_GLOBAL_DIR", path.join(fixtureRoot, "unrelated-global")); + vi.stubEnv("BUN_INSTALL_BIN", binDir); + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", undefined); + vi.spyOn(fs, "accessSync").mockImplementation(() => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }); + vi.spyOn(fs, "statSync").mockReturnValue(fileStat); + vi.spyOn(fs, "realpathSync").mockImplementation((value) => String(value)); + vi.mocked(resolveBunGlobalBinDir).mockReturnValue(binDir); + vi.mocked(inspectBunCliLauncher).mockReturnValue({ path: launcherPath, state: "missing" }); + vi.mocked(installBunCliLauncher).mockReturnValue({ path: launcherPath, state: "current" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it.each(["missing", "stale"] as const)( + "repairs a %s launcher after consent using this package's owner and absolute Bun", + async (state) => { + vi.mocked(inspectBunCliLauncher).mockReturnValue({ path: launcherPath, state }); + const prompt = prompter(); + + await noteBunCliLauncherIssues({ root, prompter: prompt }); + + expect(resolveBunGlobalBinDir).toHaveBeenCalledExactlyOnceWith({ + bunPath, + cwd: globalProject, + env: expect.objectContaining({ + BUN_INSTALL: ownerInstall, + BUN_INSTALL_GLOBAL_DIR: globalProject, + BUN_INSTALL_BIN: binDir, + }), + }); + expect(prompt.confirmAutoFix).toHaveBeenCalledExactlyOnceWith({ + message: "Repair the openclaw command for this Bun installation?", + initialValue: false, + }); + expect(installBunCliLauncher).toHaveBeenCalledExactlyOnceWith({ + packageRoot: root, + bunPath, + binDir, + }); + expect(note).toHaveBeenLastCalledWith( + `Repaired the Bun CLI launcher: ${launcherPath}`, + "Bun CLI launcher", + ); + expect(process.env.BUN_INSTALL).toBe(path.join(fixtureRoot, "unrelated-bun")); + }, + ); + + it("preserves a declined repair", async () => { + await noteBunCliLauncherIssues({ root, prompter: prompter(false) }); + expect(installBunCliLauncher).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("launcher is missing"), + "Bun CLI launcher", + ); + }); + + it.each([ + ["\n", "a newline"], + ["\r", "a carriage return"], + ])("explains unsupported path character %j without offering repair", async (character, label) => { + for (const field of ["install", "runtime"] as const) { + const unsupportedRoot = path.join( + fixtureRoot, + `owner${character}path`, + "install", + "global", + "node_modules", + "openclaw", + ); + stubRuntime( + true, + "linux", + field === "runtime" ? path.join(fixtureRoot, `bun${character}runtime`) : bunPath, + ); + const prompt = prompter(); + await noteBunCliLauncherIssues({ + root: field === "install" ? unsupportedRoot : root, + prompter: prompt, + }); + expect(note).toHaveBeenLastCalledWith( + expect.stringContaining( + `${field === "install" ? "Install path" : "Bun executable path"} contains ${label}, which cannot be stored in a launcher data line`, + ), + "Bun CLI launcher", + ); + expect(vi.mocked(note).mock.calls.at(-1)?.[0]).toContain("openclaw.mjs"); + expect(vi.mocked(note).mock.calls.at(-1)?.[0]).toContain("instead"); + expect(prompt.confirmAutoFix).not.toHaveBeenCalled(); + expect(installBunCliLauncher).not.toHaveBeenCalled(); + expect(resolveBunGlobalBinDir).not.toHaveBeenCalled(); + } + }); + + it.each([false, true])("honors noninteractive Doctor fix consent (fix=%s)", async (repair) => { + const prompt = createDoctorPrompter({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { nonInteractive: true, repair }, + }); + await noteBunCliLauncherIssues({ root, prompter: prompt }); + expect(installBunCliLauncher).toHaveBeenCalledTimes(repair ? 1 : 0); + }); + + it.each(["current", "conflict"] as const)("does not change a %s launcher", async (state) => { + vi.mocked(inspectBunCliLauncher).mockReturnValue({ path: launcherPath, state }); + const prompt = prompter(); + await noteBunCliLauncherIssues({ root, prompter: prompt }); + expect(prompt.confirmAutoFix).not.toHaveBeenCalled(); + expect(installBunCliLauncher).not.toHaveBeenCalled(); + if (state === "conflict") { + expect(note).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("belongs to another installation"), + "Bun CLI launcher", + ); + } else { + expect(note).not.toHaveBeenCalled(); + } + }); + + it.each(["Node runtime", "Windows", "persistent Node", "update", "source checkout"])( + "leaves %s launchers untouched", + async (kind) => { + if (kind === "Node runtime") { + stubRuntime(false); + } else if (kind === "Windows") { + stubRuntime(true, "win32"); + } else if (kind === "persistent Node") { + vi.mocked(fs.accessSync).mockReturnValue(undefined); + } else if (kind === "update") { + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1"); + } + const prompt = prompter(); + await noteBunCliLauncherIssues({ + root: kind === "source checkout" ? path.join(fixtureRoot, "source", "openclaw") : root, + prompter: prompt, + }); + expect(resolveBunGlobalBinDir).not.toHaveBeenCalled(); + expect(inspectBunCliLauncher).not.toHaveBeenCalled(); + expect(installBunCliLauncher).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalled(); + }, + ); + + it("ignores Bun's temporary Node shim when identifying a Bun-only install", async () => { + vi.stubEnv("PATH", path.join(fixtureRoot, "bun-node-123-abcdef")); + vi.mocked(fs.accessSync).mockReturnValue(undefined); + vi.mocked(fs.realpathSync).mockImplementation((value) => + path.basename(String(value)) === "node" ? bunPath : String(value), + ); + await noteBunCliLauncherIssues({ root, prompter: prompter() }); + expect(installBunCliLauncher).toHaveBeenCalledOnce(); + }); + + it.each(["discovery", "inspection", "repair"])( + "reports %s failures without aborting Doctor", + async (phase) => { + const failing = + phase === "discovery" + ? vi.mocked(resolveBunGlobalBinDir) + : phase === "inspection" + ? vi.mocked(inspectBunCliLauncher) + : vi.mocked(installBunCliLauncher); + failing.mockImplementation(() => { + throw new Error("fixture permission denied"); + }); + await expect( + noteBunCliLauncherIssues({ root, prompter: prompter() }), + ).resolves.toBeUndefined(); + expect(note).toHaveBeenLastCalledWith( + expect.stringContaining("Could not repair the Bun CLI launcher: fixture permission denied"), + "Bun CLI launcher", + ); + }, + ); +}); diff --git a/src/commands/doctor-bun-cli-launcher.ts b/src/commands/doctor-bun-cli-launcher.ts new file mode 100644 index 000000000000..775de30cb60c --- /dev/null +++ b/src/commands/doctor-bun-cli-launcher.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import path from "node:path"; +import { note } from "../../packages/terminal-core/src/note.js"; +import { + getBunCliLauncherPathIssue, + inspectBunCliLauncher, + installBunCliLauncher, + resolveBunGlobalBinDir, +} from "../../scripts/lib/bun-cli-launcher.mjs"; +import { quoteCliArg } from "../cli/quote-cli-arg.js"; +import { resolveBunGlobalInstallOwner } from "../infra/detect-package-manager.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +function hasPersistentNode(): boolean { + for (const directory of (process.env.PATH ?? "").split(path.delimiter)) { + const executable = path.resolve(directory || ".", "node"); + try { + fs.accessSync(executable, fs.constants.X_OK); + if (!fs.statSync(executable).isFile()) { + continue; + } + // `bun --bun` supplies a temporary Node alias, not a future CLI runtime. + if ( + /^bun-node-(?:[0-9a-f]+|[0-9]+-(?:[0-9a-f]+|debug)(?:-[0-9a-f]{16})?)$/u.test( + path.basename(path.dirname(executable)), + ) && + fs.realpathSync(executable) === fs.realpathSync(process.execPath) + ) { + continue; + } + return true; + } catch { + // PATH lookup skips absent or nonexecutable candidates without spawning them. + } + } + return false; +} + +export async function noteBunCliLauncherIssues(params: { + root: string | null; + prompter: Pick; +}): Promise { + if ( + !params.root || + !process.versions.bun || + process.platform === "win32" || + params.prompter.repairMode.updateInProgress || + hasPersistentNode() + ) { + return; + } + const owner = resolveBunGlobalInstallOwner(params.root); + if (!owner) { + return; + } + const entryPath = path.join(params.root, "openclaw.mjs"); + const pathIssue = getBunCliLauncherPathIssue({ bunPath: process.execPath, entryPath }); + if (pathIssue) { + const command = `${quoteCliArg(process.execPath)} ${quoteCliArg(entryPath)}`; + note(`${pathIssue}. Run ${command} instead.`, "Bun CLI launcher"); + return; + } + try { + const binDir = resolveBunGlobalBinDir({ + bunPath: process.execPath, + cwd: owner.globalProjectRoot, + env: { + ...process.env, + BUN_INSTALL_GLOBAL_DIR: owner.globalProjectRoot, + ...(owner.bunInstall ? { BUN_INSTALL: owner.bunInstall } : {}), + }, + }); + const launcher = { packageRoot: params.root, bunPath: process.execPath, binDir }; + const inspection = inspectBunCliLauncher(launcher); + if (inspection.state === "current") { + return; + } + if (inspection.state === "conflict") { + note( + `The openclaw command at ${inspection.path} belongs to another installation. Resolve the conflicting command before repairing this Bun installation.`, + "Bun CLI launcher", + ); + return; + } + note( + `The Bun CLI launcher is ${inspection.state}: ${inspection.path}. The openclaw command needs a launcher that uses this installation's Bun executable.`, + "Bun CLI launcher", + ); + if ( + !(await params.prompter.confirmAutoFix({ + message: "Repair the openclaw command for this Bun installation?", + initialValue: false, + })) + ) { + return; + } + const installed = installBunCliLauncher(launcher); + note(`Repaired the Bun CLI launcher: ${installed.path}`, "Bun CLI launcher"); + } catch (error) { + note( + `Could not repair the Bun CLI launcher: ${error instanceof Error ? error.message : String(error)}. Run Doctor with --fix through this package's Bun entry point after resolving the problem.`, + "Bun CLI launcher", + ); + } +} diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts index eefcd1e05958..f4a3f2d25789 100644 --- a/src/flows/doctor-health.ts +++ b/src/flows/doctor-health.ts @@ -379,11 +379,13 @@ async function runDoctorHealthFlowWithResult( // Keep side-effect-heavy legacy checks before structured contributions until fully migrated. const { maybeRepairUiProtocolFreshness } = await import("../commands/doctor-ui.js"); const { noteSourceInstallIssues } = await import("../commands/doctor-install.js"); + const { noteBunCliLauncherIssues } = await import("../commands/doctor-bun-cli-launcher.js"); const { noteStalePluginRuntimeSymlinks } = await import("../commands/doctor/shared/plugin-runtime-symlinks.js"); const { noteStartupOptimizationHints } = await import("../commands/doctor-platform-notes.js"); await maybeRepairUiProtocolFreshness(doctorRuntime, prompter); await noteSourceInstallIssues(root); + await noteBunCliLauncherIssues({ root, prompter }); await noteStalePluginRuntimeSymlinks(root); noteStartupOptimizationHints(); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 8cee32c44cf5..5c6a8e04708d 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -579,6 +579,7 @@ export async function createGlobalInstallEnv( applyNpmFreshnessBypassEnv(merged); } applyPosixNpmScriptShellEnv(merged); + // Candidate lifecycle uses this pin for Bun-only global launchers, including private staging. if (process.versions.bun) { merged.OPENCLAW_PACKAGE_BUN_LAUNCHER = process.execPath; } diff --git a/src/infra/update-native-package-stage.test.ts b/src/infra/update-native-package-stage.test.ts index 4937759e8850..313c4516dea8 100644 --- a/src/infra/update-native-package-stage.test.ts +++ b/src/infra/update-native-package-stage.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { installBunCliLauncher, parseBunCliLauncher } from "../../scripts/lib/bun-cli-launcher.mjs"; import { withTestDir } from "../test-helpers/temp-dir.js"; import type { ResolvedGlobalInstallTarget } from "./update-global.js"; import { @@ -31,11 +32,16 @@ async function writeStoredRuntime(packageRoot: string, store: string, generation } describe.skipIf(process.platform === "win32")("native package stage", () => { - it.each(["bun", "pnpm10", "pnpm11"] as const)( + it.each(["bun", "bun-launcher", "pnpm10", "pnpm11"] as const)( "preserves the live %s project and executes its relocated candidate launcher", async (layout) => { await withTestDir({ prefix: "native-package-stage-" }, async (base) => { - const project = path.join(base, "install", "global"); + const project = path.join( + base, + ...(layout === "bun-launcher" ? ["Bun ' install"] : []), + "install", + "global", + ); const globalRoot = path.join( project, layout === "pnpm11" ? "v11" : layout === "pnpm10" ? "5/node_modules" : "node_modules", @@ -76,8 +82,8 @@ describe.skipIf(process.platform === "win32")("native package stage", () => { JSON.stringify({ virtualStoreDir, storeDir: external }), ); const installTarget: ResolvedGlobalInstallTarget = { - manager: layout === "bun" ? "bun" : "pnpm", - command: layout === "bun" ? "bun" : "pnpm", + manager: layout.startsWith("bun") ? "bun" : "pnpm", + command: layout.startsWith("bun") ? "bun" : "pnpm", globalRoot, packageRoot, }; @@ -139,8 +145,15 @@ describe.skipIf(process.platform === "win32")("native package stage", () => { "external", ); const launcher = path.join(stage.binDir, "openclaw"); - if (layout === "bun") { + if (layout.startsWith("bun")) { await fs.symlink(path.relative(stage.binDir, candidateEntry), launcher); + if (layout === "bun-launcher") { + installBunCliLauncher({ + packageRoot: candidateRoot, + bunPath: process.execPath, + binDir: stage.binDir, + }); + } } else { const target = path.relative(stage.binDir, candidateEntry); await fs.writeFile( @@ -157,6 +170,12 @@ describe.skipIf(process.platform === "win32")("native package stage", () => { } expect((await runFile(launcher, [], { timeout: 5000 })).stdout.trim()).toBe("candidate"); await finalizeNativePackageStage(stage, "openclaw"); + if (layout === "bun-launcher") { + expect(parseBunCliLauncher(await fs.readFile(launcher, "utf8"))).toEqual({ + bunPath: process.execPath, + entryPath: path.join(packageRoot, "openclaw.mjs"), + }); + } expect( ( await runFile(process.execPath, [path.join(packageRoot, "openclaw.mjs")], { @@ -175,7 +194,7 @@ describe.skipIf(process.platform === "win32")("native package stage", () => { await fs.rm(stage.binDir, { recursive: true }); expect( (await runFile(path.join(liveBinDir, "openclaw"), [], { timeout: 5000 })).stdout.trim(), - ).toBe(layout === "bun" ? "candidate" : "bin-runtime\ncandidate"); + ).toBe(layout.startsWith("bun") ? "candidate" : "bin-runtime\ncandidate"); expect( (await runFile(path.join(liveBinDir, "shared"), [], { timeout: 5000 })).stdout.trim(), ).toBe("external"); diff --git a/src/infra/update-runtime-relocation.ts b/src/infra/update-runtime-relocation.ts index 8efbe23b825d..3c252a6ea303 100644 --- a/src/infra/update-runtime-relocation.ts +++ b/src/infra/update-runtime-relocation.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { parseBunCliLauncher, renderBunCliLauncher } from "../../scripts/lib/bun-cli-launcher.mjs"; import { hasErrnoCode } from "./errno.js"; import { isPathInside } from "./path-guards.js"; import { createRuntimePathLookup } from "./update-runtime-path-index.js"; @@ -91,6 +92,18 @@ export async function relocateRuntimeLauncher( ): Promise { const prepared = prepareRuntimeRelocations(relocations); const original = await fs.readFile(file, "utf8"); + const bunLauncher = parseBunCliLauncher(original); + if (bunLauncher) { + const content = renderBunCliLauncher({ + bunPath: relocateRuntimePath(bunLauncher.bunPath, prepared), + entryPath: relocateRuntimePath(bunLauncher.entryPath, prepared), + }); + if (content !== original) { + assertBeforeMutation?.(); + await fs.writeFile(file, content); + } + return; + } // pnpm cmd-shim uses these directory-relative references on sh, cmd and PowerShell. // Resolve them before changing the directory; absolute store/runtime paths stay external. let content = original.replace( diff --git a/test/scripts/bun-cli-launcher.test.ts b/test/scripts/bun-cli-launcher.test.ts new file mode 100644 index 000000000000..2973ae17a8a6 --- /dev/null +++ b/test/scripts/bun-cli-launcher.test.ts @@ -0,0 +1,418 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + inspectBunCliLauncher, + installBunCliLauncher, + parseBunCliLauncher, + renderBunCliLauncher, +} from "../../scripts/lib/bun-cli-launcher.mjs"; +import { installPackageBunCliLauncher } from "../../scripts/postinstall-bun-cli-launcher.mjs"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const lifecycle = vi.hoisted(() => ({ + resolveBinDir: vi.fn<() => string>(), + probeNode: vi.fn<() => { bunVersion: string | null } | null>(), +})); + +vi.mock("../../scripts/lib/bun-cli-launcher.mjs", async (importOriginal) => ({ + ...(await importOriginal()), + resolveBunGlobalBinDir: lifecycle.resolveBinDir, +})); + +vi.mock("../../scripts/preinstall-package-manager-warning.mjs", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../scripts/preinstall-package-manager-warning.mjs") + >()), + probePackageCliNodeRuntime: lifecycle.probeNode, +})); + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +const unsupportedPathCharacters = [ + ["\n", "a newline"], + ["\r", "a carriage return"], +] as const; + +function fixture(character = "", field: "bunPath" | "entryPath" = "entryPath") { + const root = tempDirs.make("openclaw-bun-launcher-"); + const packageRoot = join( + root, + `package 'quoted' ${field === "entryPath" ? character : ""}(space)`, + ); + const binDir = join(root, "global-bin"); + const bunPath = join(root, `runtime 'quoted' ${field === "bunPath" ? character : ""}(space)`); + mkdirSync(packageRoot); + mkdirSync(binDir); + const entryPath = join(packageRoot, "openclaw.mjs"); + writeFileSync(entryPath, "#!/usr/bin/env node\n// Installed entry stays unchanged.\n"); + return { root, packageRoot, binDir, bunPath, entryPath, path: join(binDir, "openclaw") }; +} + +describe.skipIf(process.platform === "win32")("POSIX Bun CLI launcher", () => { + it("executes quoted absolute paths with exact argv, exit status, and termination signal", () => { + const target = fixture(); + symlinkSync(process.execPath, target.bunPath); + writeFileSync( + target.entryPath, + [ + 'import { readFileSync } from "node:fs";', + 'if (process.argv[2] === "signal") process.kill(process.pid, "SIGTERM");', + 'else if (process.argv[2] === "stdin") process.stdout.write(readFileSync(0));', + "else { process.stdout.write(JSON.stringify(process.argv.slice(2))); process.exitCode = 23; }", + ].join("\n"), + ); + installBunCliLauncher(target); + + const argv = [ + "", + "two words", + "'quoted'", + '"double"', + "$(literal)", + "`literal`", + "a\\path", + "line\nbreak", + "--flag=value", + ]; + const result = spawnSync(target.path, argv, { env: { PATH: "" }, encoding: "utf8" }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual(argv); + expect(result.status).toBe(23); + expect(result.signal).toBeNull(); + + const signaled = spawnSync(target.path, ["signal"], { env: { PATH: "" }, encoding: "utf8" }); + expect(signaled.error).toBeUndefined(); + expect(signaled.status).toBeNull(); + expect(signaled.signal).toBe("SIGTERM"); + + const input = spawnSync(target.path, ["stdin"], { + env: { PATH: "" }, + encoding: "utf8", + input: "original stdin\n", + }); + expect(input.status, input.stderr).toBe(0); + expect(input.stdout).toBe("original stdin\n"); + }); + + it.each([ + "$store", + "`literal`", + '"double"', + "back\\slash", + "apostrophe's", + "two words", + "*glob*", + ])("preserves literal path bytes after released relocation: %j", (component) => { + const target = fixture(component, "bunPath"); + // A POSIX argv probe avoids Node's separate ESM restriction on backslash filenames. + writeFileSync(target.bunPath, '#!/bin/sh\n[ -f "$1" ] || exit 98\nprintf \'%s\\0\' "$@"\n', { + mode: 0o755, + }); + const content = renderBunCliLauncher(target); + const liveRoot = join(target.root, `live ${component}`); + // Published 2026.9.7 relocates raw sourceRoot + '/' bytes before retiring staging. + writeFileSync(target.path, content.replaceAll(`${target.packageRoot}/`, `${liveRoot}/`), { + mode: 0o755, + }); + renameSync(target.packageRoot, liveRoot); + + const result = spawnSync(target.path, ["two words", "$literal", ""], { + env: { PATH: "" }, + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.split("\0")).toEqual([ + join(liveRoot, "openclaw.mjs"), + "two words", + "$literal", + "", + "", + ]); + expect(content.split("\n").filter((line) => line.includes(`${target.packageRoot}/`))).toEqual([ + `#openclaw-entry=${target.entryPath}`, + ]); + expect(parseBunCliLauncher(readFileSync(target.path, "utf8"))).toEqual({ + bunPath: target.bunPath, + entryPath: join(liveRoot, "openclaw.mjs"), + }); + }); + + it("rejects a split live data line before executing even an existing truncated target", () => { + const target = fixture(); + symlinkSync(process.execPath, target.bunPath); + const truncated = join(target.root, "truncated-entry"); + writeFileSync(truncated, 'require("node:fs").writeFileSync("runtime-executed", "bad");'); + const liveRoot = `${truncated}\nprintf injected > data-executed\nsuffix`; + const content = renderBunCliLauncher(target).replaceAll( + `${target.packageRoot}/`, + `${liveRoot}/`, + ); + writeFileSync(target.path, content, { mode: 0o755 }); + renameSync(target.packageRoot, liveRoot); + + const result = spawnSync(target.path, [], { + cwd: target.root, + env: { PATH: "" }, + encoding: "utf8", + }); + expect(result.status).toBe(127); + expect(result.stderr).toContain("openclaw: Bun launcher target not found"); + expect(result.stdout).toBe(""); + expect(existsSync(join(target.root, "runtime-executed"))).toBe(false); + expect(existsSync(join(target.root, "data-executed"))).toBe(false); + expect(parseBunCliLauncher(content)).toBeNull(); + }); + + it("rejects duplicate data records introduced by a hidden newline", () => { + const target = fixture(); + symlinkSync(process.execPath, target.bunPath); + writeFileSync(target.entryPath, 'console.log("must not execute");'); + const content = renderBunCliLauncher(target).replace( + `#openclaw-entry=${target.entryPath}`, + `#openclaw-entry=${target.entryPath}\n#openclaw-entry=${target.entryPath}`, + ); + writeFileSync(target.path, content, { mode: 0o755 }); + const result = spawnSync(target.path, [], { env: { PATH: "" }, encoding: "utf8" }); + expect(result.status).toBe(127); + expect(result.stderr).toContain("openclaw: Bun launcher target not found"); + expect(result.stdout).toBe(""); + expect(parseBunCliLauncher(content)).toBeNull(); + }); + + it.each(["runtime", "entry"])("explains a missing %s without executing", (missing) => { + const target = fixture(); + if (missing === "entry") { + symlinkSync(process.execPath, target.bunPath); + unlinkSync(target.entryPath); + } + installBunCliLauncher(target); + const result = spawnSync(target.path, [], { env: { PATH: "" }, encoding: "utf8" }); + expect(result.status).toBe(127); + expect(result.stderr).toContain("openclaw: Bun launcher target not found"); + expect(result.stderr).toContain('Run "openclaw doctor" with Bun to repair.'); + }); + + it("replaces its package symlink without modifying the entry and leaves a current install alone", () => { + const target = fixture(); + const entry = readFileSync(target.entryPath, "utf8"); + symlinkSync(target.entryPath, target.path); + expect(inspectBunCliLauncher(target).state).toBe("stale"); + + expect(installBunCliLauncher(target)).toEqual({ path: target.path, state: "current" }); + expect(lstatSync(target.path).isSymbolicLink()).toBe(false); + expect(statSync(target.path).mode & 0o777).toBe(0o755); + expect(readFileSync(target.entryPath, "utf8")).toBe(entry); + expect(parseBunCliLauncher(readFileSync(target.path, "utf8"))).toEqual({ + bunPath: target.bunPath, + entryPath: target.entryPath, + }); + const installed = statSync(target.path); + installBunCliLauncher(target); + const unchanged = statSync(target.path); + expect(unchanged.ino).toBe(installed.ino); + expect(unchanged.mtimeMs).toBe(installed.mtimeMs); + expect(readdirSync(target.binDir)).toEqual(["openclaw"]); + }); + + it("installs a missing launcher, refreshes the Bun executable, and repairs executable permissions", () => { + const target = fixture(); + expect(inspectBunCliLauncher(target).state).toBe("missing"); + installBunCliLauncher(target); + const updated = { ...target, bunPath: join(target.root, "new-bun") }; + expect(inspectBunCliLauncher(updated).state).toBe("stale"); + installBunCliLauncher(updated); + expect(parseBunCliLauncher(readFileSync(target.path, "utf8"))?.bunPath).toBe(updated.bunPath); + chmodSync(target.path, 0o644); + expect(inspectBunCliLauncher(updated).state).toBe("stale"); + installBunCliLauncher(updated); + expect(inspectBunCliLauncher(updated).state).toBe("current"); + }); + + it.each(["file", "symlink", "directory"])("preserves a conflicting %s", (kind) => { + const target = fixture(); + const otherEntry = join(target.root, "other-command"); + writeFileSync(otherEntry, "other owner"); + if (kind === "file") { + writeFileSync(target.path, "other owner"); + } else if (kind === "symlink") { + symlinkSync(otherEntry, target.path); + } else { + mkdirSync(target.path); + } + + expect(inspectBunCliLauncher(target).state).toBe("conflict"); + expect(() => installBunCliLauncher(target)).toThrow("left unchanged"); + expect(readFileSync(otherEntry, "utf8")).toBe("other owner"); + if (kind === "file") { + expect(readFileSync(target.path, "utf8")).toBe("other owner"); + } else if (kind === "symlink") { + expect(readlinkSync(target.path)).toBe(otherEntry); + } else { + expect(lstatSync(target.path).isDirectory()).toBe(true); + } + expect(readdirSync(target.binDir)).toEqual(["openclaw"]); + }); + + it("does not adopt a marked launcher whose command was edited or whose package root differs", () => { + const target = fixture(); + const original = renderBunCliLauncher(target); + expect(parseBunCliLauncher(`${original}echo extra\n`)).toBeNull(); + writeFileSync(target.path, `${original}echo extra\n`); + expect(() => installBunCliLauncher(target)).toThrow("left unchanged"); + const foreign = renderBunCliLauncher({ + bunPath: target.bunPath, + entryPath: join(target.root, "another-package", "openclaw.mjs"), + }); + writeFileSync(target.path, foreign); + expect(() => installBunCliLauncher(target)).toThrow("left unchanged"); + expect(readFileSync(target.path, "utf8")).toBe(foreign); + }); +}); + +describe("Bun launcher path contract", () => { + it.each(["relative/bun", "/runtime\0next"])( + "rejects an unstable executable or entry path: %j", + (invalid) => { + expect(() => + renderBunCliLauncher({ bunPath: invalid, entryPath: "/pkg/openclaw.mjs" }), + ).toThrow("absolute, single-line"); + expect(() => renderBunCliLauncher({ bunPath: "/runtime/bun", entryPath: invalid })).toThrow( + "absolute, single-line", + ); + }, + ); +}); + +describe.skipIf(process.platform === "win32")("packaged Bun launcher lifecycle", () => { + beforeEach(() => { + lifecycle.resolveBinDir.mockReset(); + lifecycle.probeNode.mockReset().mockReturnValue({ bunVersion: "1.4.3" }); + }); + + function lifecycleFixture() { + const target = fixture(); + symlinkSync(target.entryPath, target.path); + lifecycle.resolveBinDir.mockReturnValue(target.binDir); + const env = { + npm_config_user_agent: "bun/1.4.3", + OPENCLAW_PACKAGE_BUN_LAUNCHER: target.bunPath, + }; + return { target, params: { packageRoot: target.packageRoot, env, bunVersion: "1.4.3" } }; + } + + it("finishes only its existing global link and refreshes it after a Bun update", () => { + const { target, params } = lifecycleFixture(); + installPackageBunCliLauncher(params); + expect(lifecycle.resolveBinDir).toHaveBeenCalledWith({ + bunPath: target.bunPath, + cwd: target.packageRoot, + env: params.env, + }); + expect(inspectBunCliLauncher(target).state).toBe("current"); + const updatedBun = join(target.root, "updated-bun"); + installPackageBunCliLauncher({ + ...params, + env: { ...params.env, OPENCLAW_PACKAGE_BUN_LAUNCHER: updatedBun }, + }); + expect(parseBunCliLauncher(readFileSync(target.path, "utf8"))?.bunPath).toBe(updatedBun); + }); + + it.each(unsupportedPathCharacters)( + "keeps Bun's symlink for paths containing %j", + (character, label) => { + for (const field of ["bunPath", "entryPath"] as const) { + const target = fixture(character, field); + symlinkSync(target.entryPath, target.path); + lifecycle.resolveBinDir.mockReturnValue(target.binDir); + const reason = `${field === "bunPath" ? "Bun executable path" : "Install path"} contains ${label}, which cannot be stored in a launcher data line`; + + expect(() => renderBunCliLauncher(target)).toThrow(reason); + expect(() => inspectBunCliLauncher(target)).toThrow(reason); + expect(() => installBunCliLauncher(target)).toThrow(reason); + expect(() => + installPackageBunCliLauncher({ + packageRoot: target.packageRoot, + bunVersion: "1.4.3", + env: { + npm_config_user_agent: "bun/1.4.3", + OPENCLAW_PACKAGE_BUN_LAUNCHER: target.bunPath, + }, + }), + ).not.toThrow(); + expect(readlinkSync(target.path)).toBe(target.entryPath); + expect(readdirSync(target.binDir)).toEqual(["openclaw"]); + } + }, + ); + + it.each(["missing", "foreign"])("does not claim a %s global command", (kind) => { + const target = fixture(); + if (kind === "foreign") { + writeFileSync(target.path, "other owner"); + } + lifecycle.resolveBinDir.mockReturnValue(target.binDir); + installPackageBunCliLauncher({ + packageRoot: target.packageRoot, + bunVersion: "1.4.3", + env: { npm_config_user_agent: "bun/1.4.3", OPENCLAW_PACKAGE_BUN_LAUNCHER: target.bunPath }, + }); + expect(readdirSync(target.binDir)).toEqual(kind === "missing" ? [] : ["openclaw"]); + if (kind === "foreign") { + expect(readFileSync(target.path, "utf8")).toBe("other owner"); + } + }); + + it.each(["Node runtime", "npm install", "missing marker", "relative marker", "persistent Node"])( + "leaves the Node-shebang bin untouched for %s", + (scenario) => { + const { target, params } = lifecycleFixture(); + if (scenario === "Node runtime") { + params.bunVersion = ""; + } + if (scenario === "npm install") { + params.env.npm_config_user_agent = "npm/11.0.0"; + } + if (scenario === "missing marker") { + params.env.OPENCLAW_PACKAGE_BUN_LAUNCHER = ""; + } + if (scenario === "relative marker") { + params.env.OPENCLAW_PACKAGE_BUN_LAUNCHER = "bun"; + } + if (scenario === "persistent Node") { + lifecycle.probeNode.mockReturnValue({ bunVersion: null }); + } + installPackageBunCliLauncher(params); + expect(readlinkSync(target.path)).toBe(target.entryPath); + expect(lifecycle.resolveBinDir).not.toHaveBeenCalled(); + }, + ); + + it("does not install a POSIX launcher on Windows", () => { + const { target, params } = lifecycleFixture(); + vi.stubGlobal("process", { platform: "win32" }); + try { + installPackageBunCliLauncher(params); + } finally { + vi.unstubAllGlobals(); + } + expect(readlinkSync(target.path)).toBe(target.entryPath); + expect(lifecycle.probeNode).not.toHaveBeenCalled(); + expect(lifecycle.resolveBinDir).not.toHaveBeenCalled(); + }); +});