From a05cfd475675c5b77eb70b17a67ad8ead51da152 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Jul 2026 23:44:09 -0400 Subject: [PATCH] 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 --- docs/install/bun.md | 14 ++---- openclaw.mjs | 13 ++++- src/infra/runtime-guard.test.ts | 24 ++++++++- src/infra/runtime-guard.ts | 15 ++++++ src/process/exec-spawn.ts | 9 ++++ test/openclaw-launcher.e2e.test.ts | 80 +++++++++++++++++++++++------- 6 files changed, 127 insertions(+), 28 deletions(-) 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", + ); + } }, );