feat(runtime): run OpenClaw under Bun runtimes that provide node:sqlite (#114256)

* feat(runtime): allow Bun runtimes that provide node:sqlite

* fix(process): drop execa buffer encoding under Bun spawn (Bun rejects non-spawn options)

* chore(process): cite oven-sh/bun#36049 in bun spawn workaround

* docs(install): bun with node:sqlite can run openclaw; bun install workspace caveat

* fix(process): clear execa buffer encoding under Bun without mutating read-only options
This commit is contained in:
Peter Steinberger
2026-07-26 23:44:09 -04:00
committed by GitHub
parent 992a86a28f
commit a05cfd4756
6 changed files with 127 additions and 28 deletions

View File

@@ -7,24 +7,20 @@ title: "Bun"
---
<Warning>
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.
</Warning>
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
<Steps>
<Step title="Install dependencies">
```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`.
</Step>
<Step title="Build and test">
@@ -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.
</Step>
</Steps>

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,14 @@ export function spawnCommandWithInvocation<
invocation: ReturnType<typeof resolveSafeChildProcessInvocation>;
} {
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,

View File

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