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.
This commit is contained in:
Peter Steinberger 2026-10-01 12:53:07 -07:00 • committed by GitHub
parent 777f35568c
commit c4228dc7ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1154 additions and 19 deletions

View file

@ -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
<bun> <package-root>/openclaw.mjs gateway install --runtime bun --runtime-path <bun> --force
openclaw gateway install --runtime bun --runtime-path <bun> --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@<version>`. 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
`<bun> <package-root>/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
`<bun> <package-root>/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

View file

@ -51,6 +51,28 @@ Bun remains usable as an optional package-script runner. The default package man
</Step>
</Steps>
## 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 <package-root>/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:

View file

@ -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 `<prefix>/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 `<prefix>/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.

View file

@ -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",

View file

@ -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")),

View file

@ -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;

View file

@ -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" };
}

View file

@ -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);
}

View file

@ -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" &&

View file

@ -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<typeof import("../../scripts/lib/bun-cli-launcher.mjs")>()),
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",
);
},
);
});

View file

@ -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<DoctorPrompter, "confirmAutoFix" | "repairMode">;
}): Promise<void> {
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",
);
}
}

View file

@ -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();

View file

@ -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;
}

View file

@ -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");

View file

@ -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<void> {
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(

View file

@ -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<typeof import("../../scripts/lib/bun-cli-launcher.mjs")>()),
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();
});
});