diff --git a/docs/install/bun.md b/docs/install/bun.md
index 869c38648c7f..0057daa40bde 100644
--- a/docs/install/bun.md
+++ b/docs/install/bun.md
@@ -7,24 +7,20 @@ title: "Bun"
---
-Bun cannot run the OpenClaw CLI or Gateway because it does not provide the required `node:sqlite` API. Install a supported Node version for all OpenClaw runtime commands.
+Bun releases up to 1.3.x cannot run the OpenClaw CLI or Gateway because they do not provide the required `node:sqlite` API. OpenClaw feature-probes the runtime: Bun builds that ship `node:sqlite` (1.4.0 canary and later) can run the CLI and Gateway experimentally, while older Bun versions are rejected at startup. Node remains the supported and recommended runtime for all OpenClaw runtime commands.
-Bun remains usable as an optional dependency installer and package-script runner. The default package manager remains `pnpm`, which is fully supported and used by docs tooling. Bun cannot use `pnpm-lock.yaml` and ignores it.
+Bun remains usable as an optional package-script runner. The default package manager remains `pnpm`, which is fully supported and used by docs tooling. Bun cannot use `pnpm-lock.yaml` and ignores it, and current Bun versions fail to resolve this repo's `pnpm-workspace.yaml` layout during `bun install`, so dependency installs should use `pnpm install`.
## Install
```sh
- bun install
+ pnpm install
```
- `bun.lock` / `bun.lockb` are gitignored, so there is no repo churn. To skip lockfile writes entirely:
-
- ```sh
- bun install --no-save
- ```
+ Current Bun versions (including 1.4 canary) cannot resolve this repo's pnpm workspace layout, so `bun install` fails during workspace resolution. Use `pnpm install`.
@@ -33,7 +29,7 @@ Bun remains usable as an optional dependency installer and package-script runner
bun run vitest run
```
- Commands that launch OpenClaw itself must still run through Node.
+ Commands that launch OpenClaw itself should still run through Node; Bun runtimes that provide `node:sqlite` (1.4.0 canary and later) can run them experimentally.
diff --git a/openclaw.mjs b/openclaw.mjs
index 2e3fbaf2849b..e5fdfadbdc42 100755
--- a/openclaw.mjs
+++ b/openclaw.mjs
@@ -49,8 +49,19 @@ const isSupportedNodeVersion = (version) => {
const ensureSupportedRuntimeVersion = () => {
if (process.versions.bun) {
+ // Bun >=1.4 (Rust rewrite) ships node:sqlite; feature-probe instead of
+ // rejecting Bun outright so capable Bun builds can run OpenClaw.
+ let hasNodeSqlite = false;
+ try {
+ hasNodeSqlite = Boolean(process.getBuiltinModule?.("node:sqlite"));
+ } catch {
+ hasNodeSqlite = false;
+ }
+ if (hasNodeSqlite) {
+ return;
+ }
process.stderr.write(
- "openclaw: the Bun runtime is unsupported because OpenClaw requires node:sqlite.\n" +
+ "openclaw: this Bun runtime is unsupported because it does not provide node:sqlite.\n" +
`Use Node.js ${SUPPORTED_NODE_RANGE}; Bun remains supported for installs and package scripts.\n`,
);
process.exit(1);
diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts
index 68740abcd126..37ce537d4319 100644
--- a/src/infra/runtime-guard.test.ts
+++ b/src/infra/runtime-guard.test.ts
@@ -79,6 +79,7 @@ describe("runtime-guard", () => {
version: "20.0.0",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
+ hasNodeSqlite: false,
};
expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit");
expect(runtime.error).toHaveBeenCalledOnce();
@@ -105,12 +106,31 @@ describe("runtime-guard", () => {
version: "22.22.3",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
+ hasNodeSqlite: true,
};
expect(assertSupportedRuntime(runtime, details)).toBeUndefined();
expect(runtime.exit).not.toHaveBeenCalled();
});
- it("rejects Bun because it does not provide node:sqlite", () => {
+ it("accepts Bun when the runtime provides node:sqlite", () => {
+ const runtime = {
+ log: vi.fn(),
+ error: vi.fn(),
+ exit: vi.fn(),
+ };
+ const details = {
+ kind: "bun" as const,
+ version: "1.4.0",
+ execPath: "/usr/bin/bun",
+ pathEnv: "/usr/bin",
+ hasNodeSqlite: true,
+ };
+ expect(assertSupportedRuntime(runtime, details)).toBeUndefined();
+ expect(runtime.exit).not.toHaveBeenCalled();
+ expect(runtime.error).not.toHaveBeenCalled();
+ });
+
+ it("rejects Bun when it does not provide node:sqlite", () => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
@@ -123,6 +143,7 @@ describe("runtime-guard", () => {
version: "1.3.14",
execPath: "/usr/bin/bun",
pathEnv: "/usr/bin",
+ hasNodeSqlite: false,
};
expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit");
@@ -150,6 +171,7 @@ describe("runtime-guard", () => {
version: null,
execPath: null,
pathEnv: "(not set)",
+ hasNodeSqlite: false,
};
expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit");
diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts
index d864916f5fd7..4a323a43ac70 100644
--- a/src/infra/runtime-guard.ts
+++ b/src/infra/runtime-guard.ts
@@ -33,6 +33,7 @@ type RuntimeDetails = {
version: string | null;
execPath: string | null;
pathEnv: string;
+ hasNodeSqlite: boolean;
};
const SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/;
@@ -79,14 +80,28 @@ function detectRuntime(): RuntimeDetails {
version,
execPath: process.execPath ?? null,
pathEnv: process.env.PATH ?? "(not set)",
+ hasNodeSqlite: currentRuntimeProvidesNodeSqlite(),
};
}
+// Bun >=1.4 (Rust rewrite) ships node:sqlite; older Buns do not. Feature-probe
+// instead of version-gating so the guard tracks the actual runtime capability.
+function currentRuntimeProvidesNodeSqlite(): boolean {
+ try {
+ return Boolean(process.getBuiltinModule?.("node:sqlite"));
+ } catch {
+ return false;
+ }
+}
+
/** Returns whether a detected runtime meets OpenClaw's minimum runtime contract. */
function runtimeSatisfies(details: RuntimeDetails): boolean {
if (details.kind === "node") {
return isSupportedNodeVersion(details.version);
}
+ if (details.kind === "bun") {
+ return details.hasNodeSqlite;
+ }
return false;
}
diff --git a/src/process/exec-spawn.ts b/src/process/exec-spawn.ts
index 3a02025a586d..8b347e684c6e 100644
--- a/src/process/exec-spawn.ts
+++ b/src/process/exec-spawn.ts
@@ -69,6 +69,14 @@ export function spawnCommandWithInvocation<
invocation: ReturnType;
} {
const { baseEnv, env, windowsVerbatimArguments, ...execaOptions } = options;
+ // Bun workaround (oven-sh/bun#36049): execa forwards its whole options object to
+ // child_process.spawn. Node ignores the extra `encoding` key there, but Bun's
+ // spawn feeds it into its stream constructor and throws ERR_UNKNOWN_ENCODING for
+ // execa's "buffer". Clearing it under Bun means buffered results arrive as utf8
+ // strings instead of Buffers; callers already normalize via Buffer.from. Binary-
+ // exact output paths are unverified under Bun. Remove once the Bun issue is fixed.
+ const dropBufferEncodingForBun =
+ Boolean(process.versions.bun) && execaOptions.encoding === "buffer";
const commandEnv = resolveCommandEnv({ argv, baseEnv, env });
const invocation = resolveSafeChildProcessInvocation({
argv,
@@ -78,6 +86,7 @@ export function spawnCommandWithInvocation<
});
const child = execa(invocation.command, invocation.args, {
...execaOptions,
+ ...(dropBufferEncodingForBun ? { encoding: undefined } : {}),
env: commandEnv,
extendEnv: false,
shell: false,
diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts
index 41961669dfda..8c0196f80a17 100644
--- a/test/openclaw-launcher.e2e.test.ts
+++ b/test/openclaw-launcher.e2e.test.ts
@@ -228,7 +228,7 @@ describe("openclaw launcher", () => {
}
});
- it("rejects Bun even when its Node compatibility version is new enough", async () => {
+ it("rejects Bun without node:sqlite even when its Node compatibility version is new enough", async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
path.join(fixtureRoot, "dist", "entry.js"),
@@ -241,6 +241,11 @@ describe("openclaw launcher", () => {
[
"Object.defineProperty(process.versions, 'bun', { value: '1.3.14' });",
"Object.defineProperty(process.versions, 'node', { value: '24.3.0' });",
+ // Old Bun has no node:sqlite; the launcher feature-probes instead of
+ // trusting the runtime label, so the mock must hide Node's builtin.
+ "const realGetBuiltinModule = process.getBuiltinModule.bind(process);",
+ "process.getBuiltinModule = (id) =>",
+ " id === 'node:sqlite' || id === 'sqlite' ? undefined : realGetBuiltinModule(id);",
].join("\n"),
"utf8",
);
@@ -258,10 +263,40 @@ describe("openclaw launcher", () => {
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain(
- "the Bun runtime is unsupported because OpenClaw requires node:sqlite",
+ "this Bun runtime is unsupported because it does not provide node:sqlite",
);
});
+ it("runs the CLI under Bun when the runtime provides node:sqlite", async () => {
+ const fixtureRoot = await makeLauncherFixture(fixtureRoots);
+ await fs.writeFile(
+ path.join(fixtureRoot, "dist", "entry.js"),
+ 'process.stdout.write("bun-runtime-entry\\n");\n',
+ "utf8",
+ );
+ const mockRuntime = path.join(fixtureRoot, "mock-bun-sqlite-runtime.mjs");
+ await fs.writeFile(
+ mockRuntime,
+ // Simulates Bun >=1.4 (Rust rewrite): bun-branded runtime with node:sqlite
+ // available; Node's own getBuiltinModule answers the launcher probe.
+ "Object.defineProperty(process.versions, 'bun', { value: '1.4.0' });",
+ "utf8",
+ );
+
+ const result = spawnSync(
+ process.execPath,
+ ["--import", pathToFileURL(mockRuntime).href, path.join(fixtureRoot, "openclaw.mjs")],
+ {
+ cwd: fixtureRoot,
+ env: launcherEnv(),
+ encoding: "utf8",
+ },
+ );
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain("bun-runtime-entry");
+ });
+
it("surfaces transitive entry import failures instead of masking them as missing dist", async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
@@ -418,30 +453,41 @@ describe("openclaw launcher", () => {
});
it.runIf(process.env.OPENCLAW_TEST_BUN_LAUNCHER === "1" && hasBunRuntime())(
- "rejects the real Bun runtime before loading the CLI",
+ "gates the real Bun runtime on node:sqlite availability",
async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
path.join(fixtureRoot, "dist", "entry.js"),
- "process.stdout.write('unexpected bun entry\\n');\n",
+ "process.stdout.write('bun entry ran\\n');\n",
"utf8",
);
- const result = spawnSync(
- process.env.BUN_BIN ?? "bun",
- [path.join(fixtureRoot, "openclaw.mjs")],
- {
- cwd: fixtureRoot,
- env: launcherEnv(),
- encoding: "utf8",
- },
+ const bunBin = process.env.BUN_BIN ?? "bun";
+ // Bun >=1.4 (Rust rewrite) ships node:sqlite and may run the CLI; older
+ // Buns must be rejected before the entry loads.
+ const probe = spawnSync(
+ bunBin,
+ ["-e", "process.exit(process.getBuiltinModule?.('node:sqlite') ? 0 : 1)"],
+ { encoding: "utf8" },
);
+ const bunHasNodeSqlite = probe.status === 0;
- expect(result.status).toBe(1);
- expect(result.stdout).toBe("");
- expect(result.stderr).toContain(
- "the Bun runtime is unsupported because OpenClaw requires node:sqlite",
- );
+ const result = spawnSync(bunBin, [path.join(fixtureRoot, "openclaw.mjs")], {
+ cwd: fixtureRoot,
+ env: launcherEnv(),
+ encoding: "utf8",
+ });
+
+ if (bunHasNodeSqlite) {
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain("bun entry ran");
+ } else {
+ expect(result.status).toBe(1);
+ expect(result.stdout).toBe("");
+ expect(result.stderr).toContain(
+ "this Bun runtime is unsupported because it does not provide node:sqlite",
+ );
+ }
},
);