fix(sqlite): reject runtimes vulnerable to WAL corruption (#106065)

* fix(sqlite): require WAL-reset-safe Node runtime

* docs(sqlite): document safe Node runtime floor

* fix(sqlite): defer runtime library validation until use

* fix(ci): align startup memory with Node 24.15
This commit is contained in:
Vincent Koc
2026-07-13 13:59:00 +08:00
committed by GitHub
parent 4349687b9d
commit f33ab243cf
87 changed files with 1233 additions and 659 deletions
@@ -10,7 +10,11 @@ openclaw_node_version_matches() {
*x)
[[ "${actual%%.*}" == "${requested%%.*}" ]] || return 1
if [[ "${requested%%.*}" == "22" ]]; then
openclaw_node_version_at_least "$actual" "22.19.0"
openclaw_node_version_at_least "$actual" "22.22.3"
elif [[ "${requested%%.*}" == "24" ]]; then
openclaw_node_version_at_least "$actual" "24.15.0"
elif [[ "${requested%%.*}" == "25" ]]; then
openclaw_node_version_at_least "$actual" "25.9.0"
fi
;;
*.*.*)
+1 -1
View File
@@ -4,7 +4,7 @@
## Tech Stack
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
- **Runtime**: Node 22.22.3+, 24.15+, or 25.9+ (Bun also supported for dev/scripts)
- **Language**: TypeScript (ESM, strict mode)
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
+2 -2
View File
@@ -869,7 +869,7 @@ jobs:
env:
# GitHub-hosted Linux reports a higher RSS baseline than Blacksmith for
# the same built CLI. Keep the tighter Blacksmith regression ceiling.
OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB: ${{ runner.environment == 'github-hosted' && '425' || '350' }}
OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB: ${{ runner.environment == 'github-hosted' && '425' || '400' }}
run: |
set +e
pnpm test:startup:memory
@@ -1388,7 +1388,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.19.0"
node-version: "22.22.3"
install-bun: "false"
- name: Configure Node test resources
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.19.0"
node-version: "22.22.3"
install-bun: "false"
- name: Configure Node test resources
+1 -1
View File
@@ -112,7 +112,7 @@ Skills own workflows; root owns hard policy and routing.
## Commands
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
- Runtime: Node 22.22.3+, 24.15+, or 25.9+; Node 24 recommended. Keep Node + Bun paths working.
- Package manager/runtime: repo defaults only. No swaps without approval.
- Install: `pnpm install` (keep Bun lock/patches aligned if touched). Agent dependency installation for tests/builds defaults to the selected remote box; do not reconcile a local Codex worktree just to run validation.
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
+1 -1
View File
@@ -47,7 +47,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
## Before You PR
- Use **Node 24** for source checkouts when possible. OpenClaw also supports Node 22.19+, but older Node 22 minors such as 22.17 are below the repository engine floor and can fail before `pnpm` commands run. See [Node install guidance](docs/install/node.md) if your local version is too old.
- Use **Node 24.15+** for source checkouts when possible. OpenClaw also supports Node 22.22.3+ and Node 25.9+, but Node 23, Node 22 before 22.22.3, and Node 24 before 24.15 are below the repository engine floor and can fail before `pnpm` commands run. See [Node install guidance](docs/install/node.md) if your local version is too old.
- Test locally with your OpenClaw instance
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
+2 -2
View File
@@ -93,7 +93,7 @@ Model note: while many providers and models are supported, prefer a current flag
## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24.15+ (recommended), Node 22.22.3+, or Node 25.9+**.
```bash
npm install -g openclaw@latest
@@ -106,7 +106,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24.15+ (recommended), Node 22.22.3+, or Node 25.9+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
+2 -2
View File
@@ -317,7 +317,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
### Node.js Version
OpenClaw requires **Node.js 22.19+, Node.js 23.11+, or Node.js 24+**. Node 24 is the recommended default runtime for new installs. The minimum supported Node 22 version includes important security patches:
OpenClaw requires **Node.js 22.22.3+, Node.js 24.15+, or Node.js 25.9+**. Node 24 is the recommended default runtime for new installs. These minimum versions include the upstream SQLite WAL-reset corruption fix; Node 23 is unsupported. The minimum supported Node 22 version also includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
@@ -325,7 +325,7 @@ OpenClaw requires **Node.js 22.19+, Node.js 23.11+, or Node.js 24+**. Node 24 is
Verify your Node.js version:
```bash
node --version # Should be v22.19+, v23.11+, or v24+
node --version # Should be v22.22.3+, v24.15+, or v25.9+
```
### Docker Security
+1 -1
View File
@@ -598,7 +598,7 @@ openclaw channels status
</Accordion>
<Accordion title="Bun runtime warning">
WhatsApp gateway runtime should use Node. Bun is flagged as incompatible for stable WhatsApp/Telegram gateway operation.
OpenClaw gateways require Node. Bun does not provide the `node:sqlite` API used by the canonical state store, and doctor migrates legacy Bun services to Node.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -26,7 +26,7 @@ openclaw daemon uninstall
| Subcommand | Options |
| ----------- | ------------------------------------------------------------------------------------------------ |
| `status` | `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` |
| `install` | `--port`, `--runtime <node\|bun>`, `--token`, `--wrapper <path>`, `--force`, `--json` |
| `install` | `--port`, `--runtime <node>`, `--token`, `--wrapper <path>`, `--force`, `--json` |
| `uninstall` | `--json` |
| `start` | `--json` |
| `stop` | `--json`, `--disable` (launchd only: persistently suppress KeepAlive/RunAtLoad until next start) |
+1 -1
View File
@@ -487,7 +487,7 @@ openclaw gateway restart
<AccordionGroup>
<Accordion title="Command options">
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `gateway install`: `--port`, `--runtime <node|bun>` (default: `node`), `--token`, `--wrapper <path>`, `--force`, `--json`
- `gateway install`: `--port`, `--runtime <node>` (default: `node`), `--token`, `--wrapper <path>`, `--force`, `--json`
- `gateway restart`: `--safe`, `--skip-deferral`, `--force`, `--wait <duration>`, `--json`
- `gateway uninstall|start`: `--json`
- `gateway stop`: `--disable`, `--json`
+1 -1
View File
@@ -123,7 +123,7 @@ Options:
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
- `--node-id <id>`: Override the legacy client instance ID stored in `node.json` (does not reset pairing)
- `--display-name <name>`: Override the node display name
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
- `--runtime <runtime>`: Service runtime (`node`)
- `--force`: Reinstall/overwrite if already installed
Manage the service:
+1 -1
View File
@@ -262,7 +262,7 @@ Token-based model auth (used with `--auth-choice token`):
Cloudflare AI Gateway: `--cloudflare-ai-gateway-account-id <id>`, `--cloudflare-ai-gateway-gateway-id <id>`.
Daemon install control: `--no-install-daemon` / `--skip-daemon` (aliases; skip gateway service install), `--daemon-runtime <node|bun>`.
Daemon install control: `--no-install-daemon` / `--skip-daemon` (aliases; skip gateway service install), `--daemon-runtime <node>`.
Skills: `--node-manager <npm|pnpm|bun>` (default `npm`), `--skip-skills`.
+3 -2
View File
@@ -358,8 +358,9 @@ slow or unavailable, add it behind the same Crabbox interface rather than
hardcoding a fallback.
VM baseline: Linux with a desktop-capable Chrome/Chromium, CDP access, VNC/
noVNC, Node 22+ and pnpm, an OpenClaw checkout, and outbound access to the
target transport, GitHub, model providers, and the credential broker.
noVNC, Node 22.22.3+, 24.15+, or 25.9+ and pnpm, an OpenClaw checkout, and
outbound access to the target transport, GitHub, model providers, and the
credential broker.
Secret names used across the Mantis workflows:
+1 -1
View File
@@ -831,7 +831,7 @@ Unknown settings are rejected. Crabbox credentials and backend-specific account
- `lifetime.idleTimeoutMinutes`: positive integer minutes stored for later idle-reclamation policy.
- `lifetime.maxLifetimeMinutes`: positive integer minutes stored for later lifecycle policy.
A supported Node runtime (22.19+, 23.11+, or 24+) must already be installed on the worker. The opt-in `"npm"` method also requires `npm` and outbound HTTPS access to the public npm registry. Networked toolchain setup is provider policy; bootstrap reports an actionable error instead of installing toolchains itself.
A supported Node runtime (22.22.3+, 24.15+, or 25.9+) with WAL-reset-safe SQLite must already be installed on the worker. The opt-in `"npm"` method also requires `npm` and outbound HTTPS access to the public npm registry. Networked toolchain setup is provider policy; bootstrap reports an actionable error instead of installing toolchains itself.
This foundation installs and verifies the gateway build and provides tunnel start/stop lifecycle, but it does not launch the general OpenClaw CLI. The self-contained worker entry and loop land in the next cloud-worker milestone.
+2 -2
View File
@@ -187,7 +187,7 @@ Flags:
- Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*`, migrates `openai-codex:*` auth profiles/order to `openai:*`, removes stale session/whole-agent runtime pins, and lets the repaired effective route determine whether Codex is compatible.
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.
- Embedded proxy environment cleanup for gateway services that captured shell `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` values during install or update.
- Gateway runtime best-practice checks (Node vs Bun, version-manager paths).
- Gateway runtime checks (unsupported legacy Bun services, version-manager paths).
- Gateway port collision diagnostics (default `18789`).
</Accordion>
@@ -560,7 +560,7 @@ That stages grounded durable candidates into the short-term dreaming store while
Doctor inspects the service runtime (PID, last exit status) and warns when the service is installed but not actually running. It also checks for port collisions on the gateway port (default `18789`) and reports likely causes (gateway already running, SSH tunnel).
</Accordion>
<Accordion title="17. Gateway runtime best practices">
Doctor warns when the gateway service runs on Bun or a version-managed Node path (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp and Telegram channels require Node, and version-manager paths can break after upgrades because the service does not load your shell init. Doctor offers to migrate to a system Node install when available (Homebrew/apt/choco).
Doctor warns when the gateway service runs on Bun or a version-managed Node path (`nvm`, `fnm`, `volta`, `asdf`, etc.). Bun cannot open OpenClaw's `node:sqlite` state store, so repairs migrate legacy Bun services to Node. Version-manager paths can break after upgrades because the service does not load your shell init. Doctor offers to migrate to a system Node install when available (Homebrew/apt/choco).
Newly installed or repaired macOS LaunchAgents use a canonical system PATH (`/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`) instead of copying the interactive shell PATH, so Homebrew-managed system binaries stay available while Volta, asdf, fnm, pnpm, and other version-manager directories do not change which Node child processes resolve. Linux services still keep explicit environment roots (`NVM_DIR`, `FNM_DIR`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `BUN_INSTALL`, `PNPM_HOME`) and stable user-bin directories, but guessed version-manager fallback directories are only written to the service PATH when those directories exist on disk.
+5 -5
View File
@@ -141,8 +141,8 @@ and troubleshooting see the main [FAQ](/help/faq).
</Accordion>
<Accordion title="What runtime do I need?">
Node **22.19+** is required (Node 24 recommended). `pnpm` is the repo package manager.
Bun is **not recommended** for the Gateway.
Node **22.22.3+**, **24.15+**, or **25.9+** is required (Node 24 recommended). `pnpm` is the repo package manager.
Bun can install dependencies and run package scripts, but it cannot run the OpenClaw CLI or Gateway because it lacks `node:sqlite`.
</Accordion>
<Accordion title="Does it run on Raspberry Pi?">
@@ -677,9 +677,9 @@ and troubleshooting see the main [FAQ](/help/faq).
</Accordion>
<Accordion title="Can I use Bun?">
Not recommended - Bun has runtime bugs, especially with WhatsApp and Telegram. Use
**Node** for stable gateways. If you still want to experiment, do it on a
non-production gateway without WhatsApp/Telegram.
You can use Bun to install dependencies or run package scripts. The OpenClaw CLI and
Gateway require **Node** because the canonical state store uses `node:sqlite`; Bun does
not provide that API.
</Accordion>
<Accordion title="Telegram: what goes in allowFrom?">
+1 -1
View File
@@ -98,7 +98,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
- **Open source**: MIT licensed, community-driven
**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.19+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
**What do you need?** Node 24.15+ (recommended), Node 22 LTS (`22.22.3+`) for compatibility, or Node 25.9+, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
## How it works
+1 -1
View File
@@ -41,7 +41,7 @@ curl -fsSL https://raw.githubusercontent.com/openclaw/openclaw-ansible/main/inst
1. Tailscale (mesh VPN for secure remote access)
2. UFW firewall (SSH + Tailscale ports only)
3. Docker CE + Compose V2 (default agent sandbox backend)
4. Node.js and pnpm (OpenClaw requires Node 22.19+ or 23.11+; Node 24 is recommended)
4. Node.js and pnpm (OpenClaw requires Node 22.22.3+, 24.15+, or 25.9+; Node 24 is recommended)
5. OpenClaw, installed host-based, not containerized
6. A systemd service with security hardening
+9 -6
View File
@@ -1,16 +1,16 @@
---
summary: "Bun workflow (experimental): installs and gotchas vs pnpm"
summary: "Bun workflow for installs and package scripts; Node is required at runtime"
read_when:
- You want the fastest local dev loop (bun + watch)
- You want to install dependencies or run package scripts with Bun
- You hit Bun install/patch/lifecycle script issues
title: "Bun (experimental)"
title: "Bun"
---
<Warning>
Bun is not recommended for gateway runtime (known issues with WhatsApp and Telegram). Use Node for production.
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.
</Warning>
Bun is an optional local runtime for running TypeScript directly (`bun run ...`, `bun --watch ...`). 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 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.
## Install
@@ -32,6 +32,9 @@ Bun is an optional local runtime for running TypeScript directly (`bun run ...`,
bun run build
bun run vitest run
```
Commands that launch OpenClaw itself must still run through Node.
</Step>
</Steps>
@@ -39,7 +42,7 @@ Bun is an optional local runtime for running TypeScript directly (`bun run ...`,
Bun blocks dependency lifecycle scripts unless explicitly trusted. For this repo, the commonly blocked scripts are not required:
- `baileys` `preinstall`: checks Node major >= 20 (OpenClaw requires Node 22.19+ or 23.11+, with Node 24 recommended)
- `baileys` `preinstall`: checks Node major >= 20 (OpenClaw requires Node 22.22.3+, 24.15+, or 25.9+, with Node 24 recommended)
- `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts)
If you hit a runtime issue that needs these scripts, trust them explicitly:
+3 -3
View File
@@ -9,7 +9,7 @@ title: "Install"
## System requirements
- **Node 22.19+, 23.11+, or 24+** - Node 24 is the default target; the installer script handles this automatically.
- **Node 22.22.3+, 24.15+, or 25.9+** - Node 24 is the default target; the installer script handles this automatically.
- **macOS, Linux, or Windows** - Windows users can start with the native Windows Hub app, the PowerShell CLI installer, or a WSL2 Gateway. See [Windows](/platforms/windows).
- `pnpm` is only needed if you build from source.
@@ -106,7 +106,7 @@ If you already manage Node yourself:
```
<Note>
Bun is supported for the global CLI install path. For the Gateway runtime, Node remains the recommended daemon runtime.
Bun can install the global package, but the resulting `openclaw` executable requires a supported Node runtime because OpenClaw state uses `node:sqlite`.
</Note>
</Tab>
@@ -148,7 +148,7 @@ curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -
Automated fleet provisioning.
</Card>
<Card title="Bun" href="/install/bun" icon="zap">
CLI-only usage via the Bun runtime.
Optional dependency installer and package-script runner.
</Card>
</CardGroup>
+8 -7
View File
@@ -15,7 +15,7 @@ OpenClaw ships three installer scripts, served from `openclaw.ai`.
| [`install-cli.sh`](#install-clish) | macOS / Linux / WSL | Installs Node + OpenClaw into a local prefix (`~/.openclaw`) via npm or git. No root required. |
| [`install.ps1`](#installps1) | Windows (PowerShell) | Installs Node if needed, installs OpenClaw via npm (default) or git, can run onboarding. |
All three support Node **22.19+, 23.11+, or 24+**; Node 24 is the default target for fresh installs.
All three support Node **22.22.3+, 24.15+, or 25.9+**; Node 24 is the default target for fresh installs.
## Quick commands
@@ -73,8 +73,8 @@ Recommended for most interactive installs on macOS/Linux/WSL.
Supports macOS and Linux (including WSL).
</Step>
<Step title="Ensure Node.js 24 by default">
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). On macOS, Homebrew is installed only when the installer needs it for Node or Git. Node 22.19+ and 23.11+ remain supported for compatibility.
On Alpine/musl Linux, the installer uses apk packages instead of NodeSource; the configured Alpine repositories must provide a supported Node version (Alpine 3.21 or newer at the time of writing).
Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). On macOS, Homebrew is installed only when the installer needs it for Node or Git. Node 22.22.3+, Node 24.15+, and Node 25.9+ are supported; Node 23 is unsupported.
On Alpine/musl Linux, the installer uses apk packages instead of NodeSource and verifies the actual linked SQLite version. Current stable Alpine package streams can provide a new-enough Node with vulnerable system SQLite; when that happens, use an official `node:24-alpine` container or a glibc-based host instead.
</Step>
<Step title="Ensure Git">
Installs Git if missing using the detected package manager, including Homebrew on macOS and apk on Alpine.
@@ -197,8 +197,9 @@ by default, plus git-checkout installs under the same prefix flow.
<Steps>
<Step title="Install local Node runtime">
Downloads a pinned supported Node LTS tarball (the version is embedded in the script and updated independently, default `22.22.2`) to `<prefix>/tools/node-v<version>` and verifies SHA-256.
On Alpine/musl Linux, where Node does not publish compatible tarballs for the pinned runtime, installs `nodejs` and `npm` with `apk` and links that runtime into the prefix wrapper path. The Alpine repositories must provide a supported Node version (22.19+, 23.11+, or 24+); use Alpine 3.21 or newer if older repositories only provide Node 20 or 21.
Downloads a pinned supported Node LTS tarball (the version is embedded in the script and updated independently, default `24.15.0`) to `<prefix>/tools/node-v<version>` and verifies SHA-256.
Linux ARMv7 uses Node `22.22.3` because official Node 24+ ARMv7 binaries are unavailable.
On Alpine/musl Linux, where Node does not publish compatible tarballs for the pinned runtime, installs `nodejs` and `npm` with `apk`, then verifies both Node and the actual linked SQLite library. Current stable Alpine package streams may still link vulnerable SQLite even with a new-enough Node; use an official `node:24-alpine` container or a glibc-based host when the safety check rejects the package.
</Step>
<Step title="Ensure Git">
If Git is missing, attempts install via apt/dnf/yum/apk on Linux or Homebrew on macOS.
@@ -256,7 +257,7 @@ by default, plus git-checkout installs under the same prefix flow.
| `--git \| --github` | Shortcut for git method |
| `--git-dir \| --dir <path>` | Git checkout directory (default: `~/openclaw`) |
| `--version <ver>` | OpenClaw version or dist-tag (default: `latest`) |
| `--node-version <ver>` | Node version (default: `22.22.2`) |
| `--node-version <ver>` | Node version (default: `24.15.0`; `22.22.3` on Linux ARMv7) |
| `--json` | Emit NDJSON events |
| `--onboard` | Run `openclaw onboard` after install |
| `--no-onboard` | Skip onboarding (default) |
@@ -299,7 +300,7 @@ by default, plus git-checkout installs under the same prefix flow.
Requires PowerShell 5+.
</Step>
<Step title="Ensure Node.js 24 by default">
If missing, attempts install via winget, then Chocolatey, then Scoop. If no package manager is available, the script downloads the official Node.js 24 Windows zip into `%LOCALAPPDATA%\OpenClaw\deps\portable-node` and adds it to the current process and user PATH. Node 22.19+ and 23.11+ remain supported for compatibility.
If missing, attempts install via winget, then Chocolatey, then Scoop. If no package manager is available, the script downloads the official Node.js 24 Windows zip into `%LOCALAPPDATA%\OpenClaw\deps\portable-node` and adds it to the current process and user PATH. Node 22.22.3+, Node 24.15+, and Node 25.9+ are supported; Node 23 is unsupported.
</Step>
<Step title="Install OpenClaw">
- `npm` method (default): global npm install using the selected `-Tag`, launched from a writable installer temp directory so shells opened in protected folders such as `C:\` still work
+2 -2
View File
@@ -7,7 +7,7 @@ read_when:
- "npm install -g fails with permissions or PATH issues"
---
OpenClaw requires **Node 22.19+, Node 23.11+, or Node 24+**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows; Node 22 remains supported via the active LTS line. The [installer script](/install#alternative-install-methods) detects and installs Node automatically — use this page when you want to set up Node yourself (versions, PATH, global installs).
OpenClaw requires **Node 22.22.3+, Node 24.15+, or Node 25.9+**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows; Node 22 remains supported via the active LTS line. Node 23 is unsupported. The [installer script](/install#alternative-install-methods) detects and installs Node automatically — use this page when you want to set up Node yourself (versions, PATH, global installs).
## Check your version
@@ -15,7 +15,7 @@ OpenClaw requires **Node 22.19+, Node 23.11+, or Node 24+**. **Node 24 is the de
node -v
```
`v24.x.x` or higher is the recommended default. `v22.19.x` or higher is the supported Node 22 LTS path (upgrade to Node 24 when convenient). Node 23 builds before `v23.11.0` are unsupported. If Node is missing or outside the supported range, pick an install method below.
`v24.15.0` or newer 24.x is the recommended default. `v22.22.3` or newer 22.x is the supported Node 22 LTS path; Node `v25.9.0+` is also supported. Node 23 is unsupported. If Node is missing or outside the supported range, pick an install method below.
## Install Node
+1 -1
View File
@@ -128,7 +128,7 @@ openclaw node start
openclaw node restart
```
`node install` also accepts `--context-path`, `--tls`, `--tls-fingerprint`, `--node-id` (legacy client instance ID only), `--runtime <node|bun>` (default: node), and `--force` to reinstall. `node status`, `node stop`, and `node uninstall` are also available.
`node install` also accepts `--context-path`, `--tls`, `--tls-fingerprint`, `--node-id` (legacy client instance ID only), `--runtime <node>` (default: node), and `--force` to reinstall. `node status`, `node stop`, and `node uninstall` are also available.
### Pair + name
+3 -3
View File
@@ -6,9 +6,9 @@ read_when:
title: "Platforms"
---
OpenClaw core is written in TypeScript. **Node is the recommended runtime**.
Bun is not recommended for the Gateway — known issues with WhatsApp and
Telegram channels; see [Bun (experimental)](/install/bun) for details.
OpenClaw core is written in TypeScript. **Node is the required runtime** because
the canonical state store uses `node:sqlite`. Bun remains available for
dependency installation and package scripts; see [Bun](/install/bun).
Companion apps exist for Windows Hub, macOS (menu bar app), and mobile nodes
(iOS/Android). Linux companion apps are planned, but the Gateway is fully
+5 -4
View File
@@ -7,14 +7,15 @@ read_when:
title: "Linux app"
---
The Gateway is fully supported on Linux. Node is the recommended runtime; Bun
is not recommended (known WhatsApp/Telegram issues).
The Gateway is fully supported on Linux and requires Node. Bun can still be used
as a dependency installer or package-script runner, but it cannot run OpenClaw
because it does not provide `node:sqlite`.
There is no native Linux companion app yet. Contributions are welcome.
## Quick path (VPS)
1. Install Node 24 (recommended) or Node 22.19+ (LTS, still supported).
1. Install Node 24.15+ (recommended), Node 22.22.3+ (LTS), or Node 25.9+.
2. `npm i -g openclaw@latest`
3. `openclaw onboard --install-daemon`
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
@@ -28,7 +29,7 @@ Full server guide: [Linux Server](/vps). Step-by-step VPS example:
- [Getting Started](/start/getting-started)
- [Install & updates](/install/updating)
- Optional: [Bun (experimental)](/install/bun), [Nix](/install/nix), [Docker](/install/docker)
- Optional: [Bun package workflow](/install/bun), [Nix](/install/nix), [Docker](/install/docker)
## Gateway service (systemd)
+2 -2
View File
@@ -7,7 +7,7 @@ read_when:
title: "Gateway on macOS"
---
OpenClaw.app does not bundle Node/Bun or the Gateway runtime. The macOS app
OpenClaw.app does not bundle Node or the Gateway runtime. The macOS app
expects an **external** `openclaw` CLI install, does not spawn the Gateway as
a child process, and manages a per-user launchd service to keep the Gateway
running (or attaches to an already-running local Gateway).
@@ -26,7 +26,7 @@ OpenClaw package.
## Manual recovery
Node 24 is recommended for a manual install; Node 22.19+ also works. Install
Node 24.15+ is recommended for a manual install; Node 22.22.3+ also works. Install
`openclaw` globally:
```bash
+2 -2
View File
@@ -13,8 +13,8 @@ Build and run the OpenClaw macOS application from source.
- **Xcode 26.2+** (Swift 6.2 toolchain), on the latest macOS available in
Software Update.
- **Node.js 24 & pnpm** for the gateway, CLI, and packaging scripts. Node
22.19+ also works.
- **Node.js 24.15+ & pnpm** for the gateway, CLI, and packaging scripts. Node
22.22.3+ also works.
## 1. Install dependencies
+1 -1
View File
@@ -10,7 +10,7 @@ title: "macOS signing"
[`scripts/package-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/package-mac-app.sh) builds and packages the app to a fixed path (`dist/OpenClaw.app`), then calls [`scripts/codesign-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/codesign-mac-app.sh) to sign it. TCC permissions are tied to the bundle ID and code signature; keeping both stable (and the app at a fixed path) across rebuilds keeps macOS from forgetting TCC grants (notifications, accessibility, screen recording, mic, speech).
- Debug bundle identifier defaults to `ai.openclaw.mac.debug` (override with `BUNDLE_ID=...`).
- Node: `>=22.19.0 <23` or `>=23.11.0` (repo `package.json` `engines`). The packager also builds the Control UI (`pnpm ui:build`).
- Node: `>=22.22.3 <23`, `>=24.15.0 <25`, or `>=25.9.0` (repo `package.json` `engines`). The packager also builds the Control UI (`pnpm ui:build`).
- Requires a real signing identity by default; the codesign script exits with an error if none is found and `ALLOW_ADHOC_SIGNING` is not set. Ad-hoc signing (`SIGN_IDENTITY="-"`) is explicit opt-in and does not persist TCC permissions across rebuilds. See [macOS permissions](/platforms/mac/permissions).
- Reads `SIGN_IDENTITY` from the environment (e.g. `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`, or a Developer ID Application cert). Without it, `codesign-mac-app.sh` auto-selects an identity in this order: Developer ID Application, Apple Distribution, Apple Development, then the first valid codesigning identity found.
- `CODESIGN_TIMESTAMP=auto` (default) enables trusted timestamps only for Developer ID Application signatures. Set `on`/`off` to force either way.
+1 -1
View File
@@ -25,7 +25,7 @@ Bare package specs still install from npm during the launch cutover. Use the
## Requirements
- Node 22.19+, Node 23.11+, or Node 24+, and `npm` or `pnpm`.
- Node 22.22.3+, Node 24.15+, or Node 25.9+, and `npm` or `pnpm`.
- TypeScript ESM modules.
- For in-repo bundled plugin work, clone the repository and run `pnpm install`.
Source-checkout plugin development is pnpm-only because OpenClaw discovers
+1 -1
View File
@@ -19,7 +19,7 @@ or [Provider Plugins](/plugins/sdk-provider-plugins) instead.
## Requirements
- Node 22.19+, Node 23.11+, or Node 24+.
- Node 22.22.3+, Node 24.15+, or Node 25.9+.
- TypeScript ESM package output.
- `typebox` in `dependencies` (not just `devDependencies` - the generated
plugin imports it at runtime).
+5 -5
View File
@@ -200,8 +200,8 @@ without exceptions outside doctor/import/export/debug boundaries.
No follow-up product decisions are blocking this plan. The implementation should
proceed with these assumptions:
- Use `node:sqlite` directly and require the Node 22+ runtime for this storage
path.
- Use `node:sqlite` directly and require a WAL-reset-safe Node runtime
(22.22.3+, 24.15+, or 25.9+) for this storage path.
- Keep exactly one normal configuration file. Do not move config, plugin
manifests, or Git workspaces into SQLite in this refactor.
- Runtime compatibility files are not required. Legacy JSON and JSONL files are
@@ -283,9 +283,9 @@ No additional product questions are blocking implementation.
The branch already has a real shared SQLite base:
- The runtime floor is now Node 22+: `package.json`, the CLI runtime guard,
installer defaults, macOS runtime locator, CI, and public install docs all
agree. The old Node 22 compatibility lane is removed.
- The runtime floor now requires a WAL-reset-safe Node build: 22.22.3+,
24.15+, or 25.9+. `package.json`, the CLI runtime guard, installer defaults,
macOS runtime locator, CI, and public install docs all agree.
- `src/state/openclaw-state-db.ts` opens `openclaw.sqlite`, sets WAL,
`synchronous=NORMAL`, `busy_timeout=30000`, `foreign_keys=ON`, and applies
the generated schema module derived from
+1 -1
View File
@@ -133,7 +133,7 @@ behavior and outputs, see [CLI setup reference](/start/wizard-cli-reference).
- Onboarding attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
- Native Windows: Scheduled Task first; if task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the Gateway immediately.
- **Runtime selection:** Node (recommended; required for WhatsApp/Telegram - Bun can corrupt memory on reconnect). Only Node is offered interactively; `--daemon-runtime bun` is CLI-only.
- **Runtime selection:** Node is required because the canonical runtime state store uses `node:sqlite`. Legacy Bun services are migrated to Node during repair.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist resolved plaintext token values into supervisor service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, daemon install is blocked until mode is set explicitly.
+1 -1
View File
@@ -12,7 +12,7 @@ working chat session.
## What you need
- **Node.js 22.19+, 23.11+, or 24+** (24 is the recommended default)
- **Node.js 22.22.3+, 24.15+, or 25.9+** (24 is the recommended default)
- **An API key** from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you
<Tip>
+1 -1
View File
@@ -21,7 +21,7 @@ Pick a setup workflow based on how often you want updates and whether you want t
## Prereqs (from source)
- Node 24 recommended (Node 22 LTS, currently `22.19+`, still supported)
- Node 24.15+ recommended (Node 22 LTS, currently `22.22.3+`, still supported)
- `pnpm` required for source checkouts. OpenClaw loads bundled plugins from the
`extensions/*` pnpm workspace packages in dev mode, so root `npm install` does
not prepare the full source tree.
+1 -1
View File
@@ -91,7 +91,7 @@ not install or modify anything on the remote host.
- Native Windows: Scheduled Task first
- If task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately.
- Scheduled Tasks remain preferred because they provide better supervisor status.
- Runtime selection: only Node is offered interactively. Bun can corrupt memory on WhatsApp/Telegram reconnect and is not a supported daemon runtime for those channels; pass `--daemon-runtime bun` only outside that combination.
- Runtime selection: Node is required because OpenClaw's canonical runtime state store uses `node:sqlite`.
</Step>
<Step title="Health check">
+2 -2
View File
@@ -85,8 +85,8 @@ Most skills configuration lives under `skills` in
<ParamField path="skills.install.nodeManager" type='"npm" | "pnpm" | "yarn" | "bun"' default='"npm"'>
Node package manager preference for skill installs. This only affects skill
installs the Gateway runtime should still use Node (Bun is not
recommended for WhatsApp/Telegram). `openclaw setup --node-manager` and
installs - the OpenClaw CLI and Gateway runtime require Node because the
canonical state store uses `node:sqlite`. `openclaw setup --node-manager` and
`openclaw onboard --node-manager` accept `npm`, `pnpm`, or `bun`; set
`"yarn"` directly in config for Yarn-backed skill installs.
</ParamField>
+1 -1
View File
@@ -72,7 +72,7 @@
"openclaw": "openclaw.mjs"
},
"engines": {
"node": ">=22.19.0 <23 || >=23.11.0"
"node": ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
},
"optionalDependencies": {
"sqlite-vec": "0.1.9"
+36 -32
View File
@@ -8,42 +8,53 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const MIN_NODE_MAJOR = 22;
const MIN_NODE_MINOR = 19;
const MIN_NODE_23_MINOR = 11;
const MIN_NODE_22 = { major: 22, minor: 22, patch: 3 };
const MIN_NODE_24 = { major: 24, minor: 15, patch: 0 };
const MIN_NODE_25 = { major: 25, minor: 9, patch: 0 };
const RECOMMENDED_NODE_MAJOR = 24;
const SUPPORTED_NODE_RANGE = ">=22.19.0 <23 or >=23.11.0";
const MIN_COMPILE_CACHE_NODE_24_MINOR = 15;
const SUPPORTED_NODE_RANGE = ">=22.22.3 <23, >=24.15.0 <25, or >=25.9.0";
const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED";
const parseNodeVersion = (rawVersion) => {
const [majorRaw = "0", minorRaw = "0"] = rawVersion.split(".");
const [majorRaw = "0", minorRaw = "0", patchRaw = "0"] = rawVersion.split(".");
return {
major: Number(majorRaw),
minor: Number(minorRaw),
patch: Number(patchRaw),
};
};
const isAtLeastNodeVersion = (version, minimum) => {
if (version.major !== minimum.major) {
return version.major > minimum.major;
}
if (version.minor !== minimum.minor) {
return version.minor > minimum.minor;
}
return version.patch >= minimum.patch;
};
const isSupportedNodeVersion = (version) => {
if (version.major === MIN_NODE_MAJOR) {
return version.minor >= MIN_NODE_MINOR;
if (version.major === MIN_NODE_22.major) {
return isAtLeastNodeVersion(version, MIN_NODE_22);
}
if (version.major === 23) {
return version.minor >= MIN_NODE_23_MINOR;
if (version.major === MIN_NODE_24.major) {
return isAtLeastNodeVersion(version, MIN_NODE_24);
}
return version.major > 23;
if (version.major === MIN_NODE_25.major) {
return isAtLeastNodeVersion(version, MIN_NODE_25);
}
return version.major > MIN_NODE_25.major;
};
const isNodeVersionAffectedByCompileCacheDeadlock = (rawVersion) => {
const version = parseNodeVersion(rawVersion);
return version.major === 24 && version.minor < MIN_COMPILE_CACHE_NODE_24_MINOR;
};
const shouldSkipCompileCacheForWindowsNode24 = () =>
process.platform === "win32" &&
isNodeVersionAffectedByCompileCacheDeadlock(process.versions.node);
const ensureSupportedNodeVersion = () => {
const ensureSupportedRuntimeVersion = () => {
if (process.versions.bun) {
process.stderr.write(
"openclaw: the Bun runtime is unsupported because OpenClaw requires node:sqlite.\n" +
`Use Node.js ${SUPPORTED_NODE_RANGE}; Bun remains supported for installs and package scripts.\n`,
);
process.exit(1);
}
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
return;
}
@@ -58,7 +69,7 @@ const ensureSupportedNodeVersion = () => {
process.exit(1);
};
ensureSupportedNodeVersion();
ensureSupportedRuntimeVersion();
if (tryOutputLauncherVersion(process.argv)) {
process.exit(0);
@@ -213,9 +224,7 @@ const runRespawnedChild = (command, args, env) => {
};
const respawnWithoutCompileCacheIfNeeded = () => {
const needsDisabledCompileCacheRespawn =
isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
if (!needsDisabledCompileCacheRespawn) {
if (!isSourceCheckoutLauncher()) {
return false;
}
if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
@@ -238,11 +247,7 @@ const respawnWithoutCompileCacheIfNeeded = () => {
};
const respawnWithPackagedCompileCacheIfNeeded = () => {
if (
isSourceCheckoutLauncher() ||
isNodeCompileCacheDisabled() ||
shouldSkipCompileCacheForWindowsNode24()
) {
if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
return false;
}
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
@@ -276,8 +281,7 @@ if (
!waitingForCompileCacheRespawn &&
module.enableCompileCache &&
!isNodeCompileCacheDisabled() &&
!isSourceCheckoutLauncher() &&
!shouldSkipCompileCacheForWindowsNode24()
!isSourceCheckoutLauncher()
) {
try {
module.enableCompileCache(resolvePackagedCompileCacheDirectory());
+1 -1
View File
@@ -2098,7 +2098,7 @@
"sqlite-vec": "0.1.9"
},
"engines": {
"node": ">=22.19.0 <23 || >=23.11.0"
"node": ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
},
"packageManager": "pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f"
}
+1 -1
View File
@@ -97,7 +97,7 @@ function resolveDefaultLimitsMb(platform = process.platform) {
help: platform === "darwin" ? 300 : 100,
// Plugin discovery is heavier than help, but must stay below the doctor/channel
// runtime graph that an empty metadata-only invocation must not import.
pluginsList: platform === "darwin" ? 500 : 350,
pluginsList: platform === "darwin" ? 500 : 400,
statusJson: 400,
gatewayStatus: 500,
};
+31 -4
View File
@@ -17,20 +17,47 @@ fi
echo "==> Pre-flight: ensure supported Node is already present"
node -e '
const version = process.versions.node.split(".").map(Number);
const [major, minor, patch] = process.versions.node.split(".").map(Number);
const ok =
version.length >= 2 &&
(version[0] > 22 || (version[0] === 22 && version[1] >= 19));
(major === 22 && (minor > 22 || (minor === 22 && patch >= 3))) ||
(major === 24 && minor >= 15) ||
(major === 25 && minor >= 9) ||
major >= 26;
if (!ok) {
process.stderr.write(`unsupported node ${process.versions.node}\n`);
process.exit(1);
}
let sqliteVersion;
try {
require("node:sqlite");
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
sqliteVersion = db.prepare("SELECT sqlite_version() AS version").get()?.version;
} finally {
db.close();
}
} catch {
process.stderr.write(`unsupported node ${process.versions.node}: missing node:sqlite\n`);
process.exit(1);
}
const match =
typeof sqliteVersion === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(sqliteVersion) : null;
const sqliteMajor = Number(match?.[1]);
const sqliteMinor = Number(match?.[2]);
const sqlitePatch = Number(match?.[3]);
const sqliteSafe =
sqliteMajor > 3 ||
(sqliteMajor === 3 &&
(sqliteMinor > 51 ||
(sqliteMinor === 51 && sqlitePatch >= 3) ||
(sqliteMinor === 50 && sqlitePatch >= 7) ||
(sqliteMinor === 44 && sqlitePatch >= 6)));
if (!sqliteSafe) {
process.stderr.write(
`unsupported node ${process.versions.node}: unsafe SQLite ${String(sqliteVersion)}\n`,
);
process.exit(1);
}
'
command -v npm >/dev/null
+78 -16
View File
@@ -64,14 +64,17 @@ resolve_openclaw_effective_home() {
OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)"
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}"
NODE_VERSION="${OPENCLAW_NODE_VERSION:-22.22.2}"
DEFAULT_NODE_VERSION="24.15.0"
ARMV7_DEFAULT_NODE_VERSION="22.22.3"
NODE_VERSION="${OPENCLAW_NODE_VERSION:-${DEFAULT_NODE_VERSION}}"
NODE_VERSION_REQUESTED=0
if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then
NODE_VERSION_REQUESTED=1
fi
MIN_NODE_VERSION="22.19.0"
MIN_NODE_23_VERSION="23.11.0"
SUPPORTED_NODE_VERSION_LABEL="Node 22.19+, Node 23.11+, or Node 24+"
MIN_NODE_22_VERSION="22.22.3"
MIN_NODE_24_VERSION="24.15.0"
MIN_NODE_25_VERSION="25.9.0"
SUPPORTED_NODE_VERSION_LABEL="Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+"
APK_NODE_BIN_DIR="/usr/bin"
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
@@ -92,7 +95,7 @@ Usage: install-cli.sh [options]
--git, --github Shortcut for --install-method git
--git-dir, --dir <path> Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
--version <ver> OpenClaw version (default: latest)
--node-version <ver> Node version (default: 22.22.2)
--node-version <ver> Node version (default: 24.15.0; 22.22.3 on Linux ARMv7)
--onboard Run "openclaw onboard" after install
--no-onboard Skip onboarding (default)
--set-npm-prefix Force npm prefix to ~/.npm-global if current prefix is not writable (Linux)
@@ -354,11 +357,23 @@ arch_detect() {
arch="$(uname -m)"
case "$arch" in
arm64|aarch64) echo "arm64" ;;
armv7|armv7l) echo "armv7l" ;;
x86_64|amd64) echo "x64" ;;
*) fail "Unsupported architecture: $arch" ;;
esac
}
select_node_version_for_platform() {
local os="$1"
local arch="$2"
if [[ "$NODE_VERSION_REQUESTED" == "0" && "$os" == "linux" && "$arch" == "armv7l" ]]; then
NODE_VERSION="$ARMV7_DEFAULT_NODE_VERSION"
fi
if [[ "$os" == "linux" && "$arch" == "armv7l" && "${NODE_VERSION%%.*}" != "22" ]]; then
fail "Linux ARMv7 requires Node 22.22.3+ because official Node 24+ binaries are unavailable; use --node-version 22.22.3."
fi
}
node_dir() {
echo "${PREFIX}/tools/node-v${NODE_VERSION}"
}
@@ -444,7 +459,45 @@ linked_node_is_usable() {
return 1
fi
"$(node_bin)" -e "const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(':memory:'); const statement = db.prepare('SELECT 1'); const supported = typeof statement.columns === 'function'; db.close(); if (!supported) process.exit(1);" >/dev/null 2>&1
"$(node_bin)" -e '
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
const value = db.prepare("SELECT sqlite_version() AS version").get()?.version;
const match = typeof value === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(value) : null;
const major = Number(match?.[1]);
const minor = Number(match?.[2]);
const patch = Number(match?.[3]);
const safe =
major > 3 ||
(major === 3 &&
(minor > 51 ||
(minor === 51 && patch >= 3) ||
(minor === 50 && patch >= 7) ||
(minor === 44 && patch >= 6)));
if (!safe) process.exitCode = 1;
} finally {
db.close();
}
' >/dev/null 2>&1
}
linked_node_sqlite_version() {
if [[ ! -x "$(node_bin)" ]]; then
printf 'unavailable\n'
return
fi
local version
version="$("$(node_bin)" -e '
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
process.stdout.write(String(db.prepare("SELECT sqlite_version() AS version").get()?.version ?? "unknown"));
} finally {
db.close();
}
' 2>/dev/null || true)"
printf '%s\n' "${version:-unavailable}"
}
semver_at_least() {
@@ -491,14 +544,18 @@ node_version_is_supported() {
done
if ((major == 22)); then
semver_at_least "$version" "$MIN_NODE_VERSION"
semver_at_least "$version" "$MIN_NODE_22_VERSION"
return
fi
if ((major == 23)); then
semver_at_least "$version" "$MIN_NODE_23_VERSION"
if ((major == 24)); then
semver_at_least "$version" "$MIN_NODE_24_VERSION"
return
fi
((major > 23))
if ((major == 25)); then
semver_at_least "$version" "$MIN_NODE_25_VERSION"
return
fi
((major > 25))
}
required_node_version() {
@@ -506,7 +563,7 @@ required_node_version() {
printf '%s\n' "$NODE_VERSION"
return
fi
printf '%s\n' "$MIN_NODE_VERSION"
printf '%s\n' "$MIN_NODE_22_VERSION"
}
try_link_usable_node_runtime_from_path() {
@@ -536,6 +593,7 @@ try_link_usable_node_runtime_from_path() {
install_alpine_node() {
local installed_version
local required_version
local sqlite_version
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"method\":\"apk\"}"
if try_link_usable_node_runtime_from_path; then
@@ -562,7 +620,8 @@ install_alpine_node() {
if ! linked_node_is_usable; then
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
required_version="$(required_node_version)"
fail "Alpine Node package must provide Node >= ${required_version} with node:sqlite; found ${installed_version}."
sqlite_version="$(linked_node_sqlite_version)"
fail "Alpine Node package must provide Node >= ${required_version} with WAL-reset-safe SQLite 3.51.3+ (or patched 3.50.7+/3.44.6+); found Node ${installed_version}, SQLite ${sqlite_version}."
fi
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
@@ -808,12 +867,12 @@ install_node() {
local expected_sha
local actual_sha
os="$(os_detect)"
arch="$(arch_detect)"
select_node_version_for_platform "$os" "$arch"
if ! node_version_is_supported "$NODE_VERSION"; then
fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}."
fi
os="$(os_detect)"
arch="$(arch_detect)"
dir="$(node_dir)"
if [[ "$os" == "linux" ]] && command -v apk >/dev/null 2>&1 && is_musl_linux; then
@@ -861,9 +920,11 @@ install_node() {
if ! linked_node_is_usable; then
local installed_version
local required_version
local sqlite_version
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
required_version="$(required_node_version)"
fail "Installed Node ${NODE_VERSION} must provide Node >= ${required_version} with node:sqlite; found ${installed_version}. Re-run with --node-version 22.22.2 (or newer)"
sqlite_version="$(linked_node_sqlite_version)"
fail "Installed Node ${NODE_VERSION} must provide Node >= ${required_version} with WAL-reset-safe SQLite; found Node ${installed_version}, SQLite ${sqlite_version}. Re-run with --node-version 24.15.0 (or newer)"
fi
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"version\":\"${NODE_VERSION}\"}"
}
@@ -1230,6 +1291,7 @@ main() {
RUN_ONBOARD=0
fi
select_node_version_for_platform "$(os_detect)" "$(arch_detect)"
PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
export PATH
+67 -17
View File
@@ -152,30 +152,46 @@ if ([string]::IsNullOrWhiteSpace($GitDir)) {
function Test-NodeVersionSupported {
param([string]$Version)
$versionMatch = [regex]::Match($Version, '^v?(?<major>\d+)\.(?<minor>\d+)\.')
$versionMatch = [regex]::Match($Version, '^v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)')
if (-not $versionMatch.Success) {
return $false
}
$major = [int]$versionMatch.Groups["major"].Value
$minor = [int]$versionMatch.Groups["minor"].Value
$patch = [int]$versionMatch.Groups["patch"].Value
if ($major -eq 22) {
return ($minor -ge 19)
return ($minor -gt 22 -or ($minor -eq 22 -and $patch -ge 3))
}
if ($major -eq 23) {
return ($minor -ge 11)
if ($major -eq 24) {
return ($minor -ge 15)
}
if ($major -eq 25) {
return ($minor -ge 9)
}
return ($major -gt 25)
}
function Test-NodeSqliteSupported {
try {
& node -e 'const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(":memory:"); try { const value = db.prepare("SELECT sqlite_version() AS version").get()?.version; const match = typeof value === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(value) : null; const major = Number(match?.[1]); const minor = Number(match?.[2]); const patch = Number(match?.[3]); const safe = major > 3 || (major === 3 && (minor > 51 || (minor === 51 && patch >= 3) || (minor === 50 && patch >= 7) || (minor === 44 && patch >= 6))); if (!safe) process.exitCode = 1; } finally { db.close(); }' 2>$null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
return ($major -gt 23)
}
function Check-Node {
try {
$nodeVersion = (node -v 2>$null)
if ($nodeVersion) {
if (Test-NodeVersionSupported -Version $nodeVersion) {
if ((Test-NodeVersionSupported -Version $nodeVersion) -and (Test-NodeSqliteSupported)) {
Write-Host "[OK] Node.js $nodeVersion found" -ForegroundColor Green
return $true
} elseif (Test-NodeVersionSupported -Version $nodeVersion) {
Write-Host "[!] Node.js $nodeVersion uses an unsafe SQLite build; SQLite 3.51.3+ (or patched 3.50.7+/3.44.6+) is required" -ForegroundColor Yellow
return $false
} else {
Write-Host "[!] Node.js $nodeVersion found, but Node 22.19+, Node 23.11+, or Node 24+ is required" -ForegroundColor Yellow
Write-Host "[!] Node.js $nodeVersion found, but Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+ is required" -ForegroundColor Yellow
return $false
}
}
@@ -371,10 +387,10 @@ function Install-Node {
# Try winget first (Windows 11 / Windows 10 with App Installer)
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Host " Using winget..." -ForegroundColor Gray
winget install OpenJS.NodeJS.LTS --source winget --accept-package-agreements --accept-source-agreements
winget install OpenJS.NodeJS.LTS --source winget --accept-package-agreements --accept-source-agreements | Out-Host
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Refresh-ProcessPath
if (Check-Node) {
Write-Host "[OK] Node.js installed via winget" -ForegroundColor Green
return $true
@@ -387,20 +403,33 @@ function Install-Node {
# Try Chocolatey
if (Get-Command choco -ErrorAction SilentlyContinue) {
Write-Host " Using Chocolatey..." -ForegroundColor Gray
choco install nodejs-lts -y
choco upgrade nodejs-lts -y --install-if-not-installed | Out-Host
# Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
Write-Host "[OK] Node.js installed via Chocolatey" -ForegroundColor Green
return $true
Refresh-ProcessPath
if (Check-Node) {
Write-Host "[OK] Node.js installed via Chocolatey" -ForegroundColor Green
return $true
}
Write-Host "[!] Chocolatey completed, but the installed Node.js runtime is unsupported" -ForegroundColor Yellow
return $false
}
# Try Scoop
if (Get-Command scoop -ErrorAction SilentlyContinue) {
Write-Host " Using Scoop..." -ForegroundColor Gray
scoop install nodejs-lts
Write-Host "[OK] Node.js installed via Scoop" -ForegroundColor Green
return $true
scoop update | Out-Host
scoop install nodejs-lts | Out-Host
scoop update nodejs-lts | Out-Host
# Refresh PATH
Refresh-ProcessPath
if (Check-Node) {
Write-Host "[OK] Node.js installed via Scoop" -ForegroundColor Green
return $true
}
Write-Host "[!] Scoop completed, but the installed Node.js runtime is unsupported" -ForegroundColor Yellow
return $false
}
try {
@@ -416,7 +445,7 @@ function Install-Node {
Write-Host ""
Write-Host "Error: Could not install Node.js automatically." -ForegroundColor Red
Write-Host ""
Write-Host "Please install Node.js 22+ manually:" -ForegroundColor Yellow
Write-Host "Please install Node.js 24.15+ manually:" -ForegroundColor Yellow
Write-Host " https://nodejs.org/en/download/" -ForegroundColor Cyan
Write-Host ""
Write-Host "Or install winget (App Installer) from the Microsoft Store." -ForegroundColor Gray
@@ -459,6 +488,27 @@ function Add-ToProcessPath {
$env:Path = "$PathEntry;$env:Path"
}
function Refresh-ProcessPath {
$entries = New-Object System.Collections.Generic.List[string]
$pathValues = @(
[Environment]::GetEnvironmentVariable("Path", "Machine"),
[Environment]::GetEnvironmentVariable("Path", "User"),
$env:Path
)
foreach ($pathValue in $pathValues) {
foreach ($entry in @($pathValue -split ";")) {
if (
-not [string]::IsNullOrWhiteSpace($entry) -and
-not ($entries | Where-Object { $_ -ieq $entry })
) {
$entries.Add($entry)
}
}
}
$env:Path = $entries -join ";"
}
function Add-ToUserPath {
param(
[Parameter(Mandatory = $true)]
+92 -29
View File
@@ -18,9 +18,13 @@ NC='\033[0m' # No Color
DEFAULT_TAGLINE="All your chats, one OpenClaw."
NODE_DEFAULT_MAJOR=24
NODE_MIN_MAJOR=22
NODE_MIN_MINOR=19
NODE_23_MIN_MINOR=11
NODE_SUPPORTED_VERSION_LABEL="22.19+, 23.11+, or 24+"
NODE_22_MIN_MINOR=22
NODE_22_MIN_PATCH=3
NODE_24_MIN_MINOR=15
NODE_24_MIN_PATCH=0
NODE_25_MIN_MINOR=9
NODE_25_MIN_PATCH=0
NODE_SUPPORTED_VERSION_LABEL="22.22.3+, 24.15.0+, or 25.9.0+"
ORIGINAL_PATH="${PATH:-}"
@@ -1535,13 +1539,17 @@ parse_node_version_components_for_binary() {
if ! command -v "$node_bin" &> /dev/null && [[ ! -x "$node_bin" ]]; then
return 1
fi
local version major minor
local version major minor patch
version="$("$node_bin" -v 2>/dev/null || true)"
major="${version#v}"
major="${major%%.*}"
minor="${version#v}"
minor="${minor#*.}"
minor="${minor%%.*}"
patch="${version#v}"
patch="${patch#*.}"
patch="${patch#*.}"
patch="${patch%%.*}"
if [[ ! "$major" =~ ^[0-9]+$ ]]; then
return 1
@@ -1549,7 +1557,10 @@ parse_node_version_components_for_binary() {
if [[ ! "$minor" =~ ^[0-9]+$ ]]; then
return 1
fi
echo "${major} ${minor}"
if [[ ! "$patch" =~ ^[0-9]+$ ]]; then
return 1
fi
echo "${major} ${minor} ${patch}"
return 0
}
@@ -1561,9 +1572,9 @@ parse_node_version_components() {
}
node_major_version() {
local version_components major minor
local version_components major minor patch
version_components="$(parse_node_version_components || true)"
read -r major minor <<< "$version_components"
read -r major minor patch <<< "$version_components"
if [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]]; then
echo "$major"
return 0
@@ -1574,37 +1585,88 @@ node_major_version() {
node_version_components_are_supported() {
local major="$1"
local minor="$2"
if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then
return 0
fi
if [[ "$major" -eq 23 && "$minor" -ge "$NODE_23_MIN_MINOR" ]]; then
return 0
fi
if [[ "$major" -gt 23 ]]; then
return 0
fi
return 1
local patch="$3"
case "$major" in
"$NODE_MIN_MAJOR")
((minor > NODE_22_MIN_MINOR)) ||
((minor == NODE_22_MIN_MINOR && patch >= NODE_22_MIN_PATCH))
;;
24)
((minor > NODE_24_MIN_MINOR)) ||
((minor == NODE_24_MIN_MINOR && patch >= NODE_24_MIN_PATCH))
;;
25)
((minor > NODE_25_MIN_MINOR)) ||
((minor == NODE_25_MIN_MINOR && patch >= NODE_25_MIN_PATCH))
;;
*)
((major > 25))
;;
esac
}
node_binary_has_safe_sqlite() {
local node_bin="$1"
"$node_bin" -e '
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
const value = db.prepare("SELECT sqlite_version() AS version").get()?.version;
const match = typeof value === "string" ? /^(\d+)\.(\d+)\.(\d+)$/.exec(value) : null;
const major = Number(match?.[1]);
const minor = Number(match?.[2]);
const patch = Number(match?.[3]);
const safe =
major > 3 ||
(major === 3 &&
(minor > 51 ||
(minor === 51 && patch >= 3) ||
(minor === 50 && patch >= 7) ||
(minor === 44 && patch >= 6)));
if (!safe) process.exitCode = 1;
} finally {
db.close();
}
' >/dev/null 2>&1
}
node_binary_sqlite_version() {
local node_bin="$1"
local version
version="$("$node_bin" -e '
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
process.stdout.write(String(db.prepare("SELECT sqlite_version() AS version").get()?.version ?? "unknown"));
} finally {
db.close();
}
' 2>/dev/null || true)"
printf '%s\n' "${version:-unavailable}"
}
node_is_supported() {
local version_components major minor
local version_components major minor patch
version_components="$(parse_node_version_components || true)"
read -r major minor <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then
read -r major minor patch <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ || ! "$patch" =~ ^[0-9]+$ ]]; then
return 1
fi
node_version_components_are_supported "$major" "$minor"
node_version_components_are_supported "$major" "$minor" "$patch" &&
node_binary_has_safe_sqlite node
}
node_binary_is_supported() {
local node_bin="$1"
local version_components major minor
local version_components major minor patch
version_components="$(parse_node_version_components_for_binary "$node_bin" || true)"
read -r major minor <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then
read -r major minor patch <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ || ! "$patch" =~ ^[0-9]+$ ]]; then
return 1
fi
node_version_components_are_supported "$major" "$minor"
node_version_components_are_supported "$major" "$minor" "$patch" &&
node_binary_has_safe_sqlite "$node_bin"
}
prepend_path_dir() {
@@ -1858,7 +1920,7 @@ install_node_with_apk() {
local apk_node_version
apk_node_version="$(node -v 2>/dev/null || echo "missing")"
ui_warn "Alpine nodejs package installed ${apk_node_version}, outside the supported range (${NODE_SUPPORTED_VERSION_LABEL})"
ui_warn "Alpine nodejs package installed ${apk_node_version}, which does not meet the Node and SQLite runtime contract"
ui_info "Trying Alpine nodejs-current package"
if is_root; then
run_required_step "Installing nodejs-current" apk add --no-cache nodejs-current npm
@@ -1872,11 +1934,12 @@ install_node_with_apk() {
return 0
fi
local active_path active_version
local active_path active_version sqlite_version
active_path="$(command -v node 2>/dev/null || echo "not found")"
active_version="$(node -v 2>/dev/null || echo "missing")"
ui_error "Alpine apk repositories did not provide Node.js ${NODE_SUPPORTED_VERSION_LABEL}; found ${active_version} (${active_path})"
echo "Use Alpine 3.21+ or install Node.js ${NODE_DEFAULT_MAJOR} manually, then rerun the installer."
sqlite_version="$(node_binary_sqlite_version node)"
ui_error "Alpine apk repositories did not provide Node.js with WAL-reset-safe SQLite; found ${active_version} with SQLite ${sqlite_version} (${active_path})"
echo "Use an official node:${NODE_DEFAULT_MAJOR}-alpine container or a glibc-based host until Alpine ships patched SQLite, then rerun the installer."
exit 1
}
+1 -1
View File
@@ -122,7 +122,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
}
const runtimeRaw = opts.runtime ? opts.runtime : DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")');
fail('Invalid --runtime (use "node"; Bun lacks the required node:sqlite API)');
return;
}
let wrapperPath: string | undefined;
@@ -84,7 +84,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
.command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--runtime <runtime>", "Daemon runtime (node). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--wrapper <path>", "Executable wrapper for generated service ProgramArguments")
.option("--force", "Reinstall/overwrite if already installed", false)
+1 -1
View File
@@ -120,7 +120,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
const runtimeRaw = opts.runtime ? opts.runtime : DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")');
fail('Invalid --runtime (use "node"; Bun lacks the required node:sqlite API)');
return;
}
+1 -1
View File
@@ -130,7 +130,7 @@ export function registerNodeCli(program: Command) {
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override the generated node instance id")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--runtime <runtime>", "Service runtime (node). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
+1 -1
View File
@@ -232,7 +232,7 @@ export function registerOnboardCommand(program: Command): void {
.option("--install-daemon", "Install gateway service")
.option("--no-install-daemon", "Skip gateway service install")
.option("--skip-daemon", "Skip gateway service install")
.option("--daemon-runtime <runtime>", "Daemon runtime: node|bun")
.option("--daemon-runtime <runtime>", "Daemon runtime: node")
.option("--skip-channels", "Skip channel setup")
.option("--skip-skills", "Skip skills setup")
.option("--skip-bootstrap", "Skip creating default agent workspace files")
+1 -1
View File
@@ -91,7 +91,7 @@ export function registerSetupCommand(program: Command): void {
.option("--install-daemon", "Install gateway service")
.option("--no-install-daemon", "Skip gateway service install")
.option("--skip-daemon", "Skip gateway service install")
.option("--daemon-runtime <runtime>", "Daemon runtime: node|bun")
.option("--daemon-runtime <runtime>", "Daemon runtime: node")
.option("--skip-channels", "Skip channel setup")
.option("--skip-skills", "Skip skills setup")
.option("--skip-bootstrap", "Skip creating default agent workspace files")
+1 -1
View File
@@ -1435,7 +1435,7 @@ async function resolvePackageRuntimePreflightError(params: {
`The requested package requires ${status.nodeEngine}.`,
runtime.nodeRunner
? "Upgrade the Node runtime that owns the managed Gateway service, then rerun `openclaw update`."
: "Upgrade to Node 22.19 or newer 22.x, Node 23.11+, or Node 24+, then rerun `openclaw update`.",
: "Upgrade to Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+, then rerun `openclaw update`.",
"Bare `npm i -g openclaw` can silently install an older compatible release.",
"After upgrading Node, use `npm i -g openclaw@latest`.",
].join("\n");
@@ -18,19 +18,6 @@ afterEach(() => {
});
describe("emitNodeRuntimeWarning", () => {
it("skips lookup when runtime is not node", async () => {
const warn = vi.fn();
await emitNodeRuntimeWarning({
env: {},
runtime: "bun",
warn,
title: "Gateway runtime",
});
expect(mocks.resolveSystemNodeInfo).not.toHaveBeenCalled();
expect(mocks.renderSystemNodeWarning).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it("emits warning when system node check returns one", async () => {
const warn = vi.fn();
mocks.resolveSystemNodeInfo.mockResolvedValue({ path: "/usr/bin/node", version: "18.0.0" });
@@ -1,19 +1,17 @@
// Runtime warning helpers for daemon install plans that depend on Node.
import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type DaemonInstallWarnFn = (message: string, title?: string) => void;
/** Warn when daemon install will use a system Node path that may be unsuitable. */
export async function emitNodeRuntimeWarning(params: {
env: Record<string, string | undefined>;
runtime: string;
runtime: GatewayDaemonRuntime;
nodeProgram?: string;
warn?: DaemonInstallWarnFn;
title: string;
}): Promise<void> {
if (params.runtime !== "node") {
return;
}
const systemNode = await resolveSystemNodeInfo({ env: params.env });
const warning = renderSystemNodeWarning(systemNode, params.nodeProgram);
if (warning) {
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
GATEWAY_DAEMON_RUNTIME_OPTIONS,
isGatewayDaemonRuntime,
} from "./daemon-runtime.js";
describe("gateway daemon runtime", () => {
it("accepts only the Node runtime", () => {
expect(DEFAULT_GATEWAY_DAEMON_RUNTIME).toBe("node");
expect(GATEWAY_DAEMON_RUNTIME_OPTIONS.map((option) => option.value)).toEqual(["node"]);
expect(isGatewayDaemonRuntime("node")).toBe(true);
expect(isGatewayDaemonRuntime("bun")).toBe(false);
expect(isGatewayDaemonRuntime(undefined)).toBe(false);
});
});
+4 -4
View File
@@ -1,5 +1,5 @@
// Gateway daemon runtime option definitions used by install/configure flows.
export type GatewayDaemonRuntime = "node" | "bun";
export type GatewayDaemonRuntime = "node";
export const DEFAULT_GATEWAY_DAEMON_RUNTIME: GatewayDaemonRuntime = "node";
@@ -10,12 +10,12 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
}> = [
{
value: "node",
label: "Node (recommended)",
hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.",
label: "Node",
hint: "Required for OpenClaw's SQLite-backed runtime state.",
},
];
/** Narrow arbitrary input to a supported Gateway daemon runtime id. */
export function isGatewayDaemonRuntime(value: string | undefined): value is GatewayDaemonRuntime {
return value === "node" || value === "bun";
return value === "node";
}
+3 -3
View File
@@ -457,14 +457,14 @@ describe("maybeRepairGatewayServiceConfig", () => {
});
it("does not duplicate gateway runtime warnings already emitted by the node install plan", async () => {
const nvmNode = "/home/orin/.nvm/versions/node/v22.22.2/bin/node";
const nvmNode = "/home/test/.nvm/versions/node/v22.22.3/bin/node";
mocks.readCommand.mockResolvedValue({
programArguments: [nvmNode, "/usr/local/bin/openclaw", "gateway", "--port", "18789"],
environment: {},
});
mocks.buildGatewayInstallPlan.mockImplementation(async ({ warn }) => {
warn?.(
"System Node 20.20.2 at /usr/bin/node is outside the supported range. Using /home/orin/.nvm/versions/node/v22.22.2/bin/node for the daemon.",
"System Node 20.20.2 at /usr/bin/node is outside the supported range. Using /home/test/.nvm/versions/node/v22.22.3/bin/node for the daemon.",
"Gateway runtime",
);
return {
@@ -492,7 +492,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect(runtimeMessages).not.toContain("duplicate doctor runtime warning");
expect(runtimeMessages.map((message) => String(message)).join("\n")).not.toContain("not found");
expect(runtimeMessages.map((message) => String(message)).join("\n")).toContain(
"Using /home/orin/.nvm/versions/node/v22.22.2/bin/node",
"Using /home/test/.nvm/versions/node/v22.22.3/bin/node",
);
});
+2 -2
View File
@@ -116,7 +116,7 @@ function detectGatewayRuntime(programArguments: string[] | undefined): GatewayDa
if (first) {
const base = normalizeLowercaseStringOrEmpty(path.basename(first));
if (base === "bun" || base === "bun.exe") {
return "bun";
return DEFAULT_GATEWAY_DAEMON_RUNTIME; // Legacy Bun services cannot open node:sqlite state.
}
if (base === "node" || base === "node.exe") {
return "node";
@@ -556,7 +556,7 @@ export async function maybeRepairGatewayServiceConfig(
note(warning, "Gateway runtime");
} else {
note(
"System Node 22 LTS (22.19+) or Node 24 not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"System Node 22 LTS (22.22.3+) or Node 24.15+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
@@ -48,7 +48,7 @@ export async function installGatewayDaemonNonInteractive(params: {
}
if (!isGatewayDaemonRuntime(daemonRuntimeRaw)) {
runtime.error('Invalid --daemon-runtime. Use "node" or "bun".');
runtime.error('Invalid --daemon-runtime. Use "node"; Bun lacks the required node:sqlite API.');
runtime.exit(1);
return { installed: false };
}
+6 -52
View File
@@ -1,12 +1,6 @@
// Daemon program argument tests cover CLI argument construction for services.
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetWindowsInstallRootsForTests } from "../infra/windows-install-roots.js";
import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
const childProcessMocks = vi.hoisted(() => ({
execFileSync: vi.fn(),
}));
const fsMocks = vi.hoisted(() => ({
access: vi.fn(),
@@ -30,14 +24,6 @@ vi.mock("node:fs/promises", async () => {
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFileSync: childProcessMocks.execFileSync,
};
});
import { resolveGatewayProgramArguments, resolveNodeProgramArguments } from "./program-args.js";
const originalArgv = [...process.argv];
@@ -46,7 +32,6 @@ afterEach(() => {
process.argv = [...originalArgv];
vi.resetAllMocks();
vi.unstubAllEnvs();
resetWindowsInstallRootsForTests();
});
describe("resolveGatewayProgramArguments", () => {
@@ -159,22 +144,24 @@ describe("resolveGatewayProgramArguments", () => {
]);
});
it("uses src/entry.ts for bun dev mode", async () => {
it("uses Node with tsx for source-checkout dev mode", async () => {
const repoIndexPath = path.resolve("/repo/src/index.ts");
const repoEntryPath = path.resolve("/repo/src/entry.ts");
process.argv = ["/usr/local/bin/node", repoIndexPath];
fsMocks.realpath.mockResolvedValue(repoIndexPath);
fsMocks.access.mockResolvedValue(undefined);
childProcessMocks.execFileSync.mockReturnValue("/usr/local/bin/bun\n");
const result = await resolveGatewayProgramArguments({
dev: true,
port: 18789,
runtime: "bun",
runtime: "node",
nodePath: "/usr/local/bin/node",
});
expect(result.programArguments).toEqual([
"/usr/local/bin/bun",
"/usr/local/bin/node",
"--import",
"tsx",
repoEntryPath,
"gateway",
"--port",
@@ -183,39 +170,6 @@ describe("resolveGatewayProgramArguments", () => {
expect(result.workingDirectory).toBe(path.resolve("/repo"));
});
it("uses trusted Windows where.exe when resolving dev runtime binaries", async () => {
const repoIndexPath = path.resolve("/repo/src/index.ts");
const repoEntryPath = path.resolve("/repo/src/entry.ts");
process.argv = [String.raw`D:\nodejs\node.exe`, repoIndexPath];
vi.stubEnv("SystemRoot", String.raw`D:\Windows`);
resetWindowsInstallRootsForTests({ queryRegistryValue: () => null });
fsMocks.realpath.mockResolvedValue(repoIndexPath);
fsMocks.access.mockResolvedValue(undefined);
childProcessMocks.execFileSync.mockReturnValue(String.raw`D:\Tools\bun.exe` + "\r\n");
let result: Awaited<ReturnType<typeof resolveGatewayProgramArguments>> | undefined;
await withMockedWindowsPlatform(async () => {
result = await resolveGatewayProgramArguments({
dev: true,
port: 18789,
runtime: "bun",
});
});
expect(childProcessMocks.execFileSync).toHaveBeenCalledWith(
path.win32.join(String.raw`D:\Windows`, "System32", "where.exe"),
["bun"],
{ encoding: "utf8" },
);
expect(result?.programArguments).toEqual([
String.raw`D:\Tools\bun.exe`,
repoEntryPath,
"gateway",
"--port",
"18789",
]);
});
it("uses an executable wrapper when provided", async () => {
const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler");
fsMocks.stat.mockResolvedValue({ isFile: () => true } as never);
+12 -68
View File
@@ -9,14 +9,14 @@ import {
findFirstAccessibleGatewayEntrypoint,
isGatewayDistEntrypointPath,
} from "./gateway-entrypoint.js";
import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js";
import { isNodeRuntime } from "./runtime-binary.js";
type GatewayProgramArgs = {
programArguments: string[];
workingDirectory?: string;
};
type GatewayRuntimePreference = "auto" | "node" | "bun";
type GatewayRuntimePreference = "auto" | "node";
export const OPENCLAW_WRAPPER_ENV_KEY = "OPENCLAW_WRAPPER";
@@ -155,11 +155,6 @@ function resolveRepoRootForDev(): string {
return parts.slice(0, srcIndex).join(path.sep);
}
async function resolveBunPath(): Promise<string> {
const bunPath = await resolveBinaryPath("bun");
return bunPath;
}
async function resolveNodePath(): Promise<string> {
const nodePath = await resolveBinaryPath("node");
return nodePath;
@@ -176,11 +171,8 @@ async function resolveBinaryPath(binary: string): Promise<string> {
await fs.access(resolved);
return resolved;
} catch {
if (binary === "bun") {
throw new Error("Bun not found in PATH. Install bun: https://bun.sh");
}
throw new Error(
"Node not found in PATH. Install Node 24 (recommended) or Node 22 LTS (22.19+).",
"Node not found in PATH. Install Node 24.15+ (recommended) or Node 22 LTS (22.22.3+).",
);
}
}
@@ -224,70 +216,22 @@ async function resolveCliProgramArguments(params: {
}
const execPath = process.execPath;
const runtime = params.runtime ?? "auto";
const nodePath =
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
if (runtime === "node") {
const nodePath =
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
const cliEntrypointPath = await resolveCliEntrypointPathForService();
if (params.dev) {
const repoRoot = resolveRepoRootForDev();
const devCliPath = path.join(repoRoot, "src", "entry.ts");
await fs.access(devCliPath);
return {
programArguments: [nodePath, cliEntrypointPath, ...params.args],
};
}
if (runtime === "bun") {
if (params.dev) {
const repoRoot = resolveRepoRootForDev();
const devCliPath = path.join(repoRoot, "src", "entry.ts");
await fs.access(devCliPath);
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
return {
programArguments: [bunPath, devCliPath, ...params.args],
workingDirectory: repoRoot,
};
}
const bunPath = isBunRuntime(execPath) ? execPath : await resolveBunPath();
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [bunPath, cliEntrypointPath, ...params.args],
};
}
if (!params.dev) {
try {
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [execPath, cliEntrypointPath, ...params.args],
};
} catch (error) {
// Non-Node runtimes may execute the CLI wrapper directly; Node needs the
// built dist entrypoint so service restarts survive package layout.
if (!isNodeRuntime(execPath)) {
return { programArguments: [execPath, ...params.args] };
}
throw error;
}
}
// Dev mode: use bun to run TypeScript directly.
const repoRoot = resolveRepoRootForDev();
const devCliPath = path.join(repoRoot, "src", "entry.ts");
await fs.access(devCliPath);
// If already running under bun, use current execPath.
if (isBunRuntime(execPath)) {
return {
programArguments: [execPath, devCliPath, ...params.args],
programArguments: [nodePath, "--import", "tsx", devCliPath, ...params.args],
workingDirectory: repoRoot,
};
}
// Otherwise resolve bun from PATH.
const bunPath = await resolveBunPath();
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [bunPath, devCliPath, ...params.args],
workingDirectory: repoRoot,
programArguments: [nodePath, cliEntrypointPath, ...params.args],
};
}
+110 -45
View File
@@ -45,19 +45,26 @@ function mockNodePathPresent(...nodePaths: string[]) {
});
}
function nodeRuntime(nodeVersion: string, sqliteVersion: string | null = "3.51.3") {
return {
stdout: `${JSON.stringify({ nodeVersion, sqliteVersion })}\n`,
stderr: "",
};
}
describe("resolvePreferredNodePath", () => {
const darwinNode = "/opt/homebrew/bin/node";
const fnmNode = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node";
const fnmNode = "/Users/test/.fnm/node-versions/v24.15.0/installation/bin/node";
const linuxSystemNode = "/usr/bin/node";
const nvmNode = "/home/test/.nvm/versions/node/v24.14.1/bin/node";
const nvmNode = "/home/test/.nvm/versions/node/v24.15.0/bin/node";
it("prefers supported system node over version-manager execPath", async () => {
mockNodePathPresent(darwinNode);
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" });
.mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({
env: {},
@@ -76,8 +83,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" });
.mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({
env: {},
@@ -97,8 +104,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" });
.mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({
env: {},
@@ -118,8 +125,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" });
.mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({
env: {},
@@ -139,8 +146,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "22.19.0\n", stderr: "" });
.mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce(nodeRuntime("22.22.3"));
const result = await resolvePreferredNodePath({
env: {},
@@ -159,8 +166,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" });
.mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce(nodeRuntime("18.0.0", null));
const result = await resolvePreferredNodePath({
env: {},
@@ -179,8 +186,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" }) // execPath too old
.mockResolvedValueOnce({ stdout: "22.19.0\n", stderr: "" }); // system node ok
.mockResolvedValueOnce(nodeRuntime("18.0.0", null)) // execPath too old
.mockResolvedValueOnce(nodeRuntime("22.22.3")); // system node ok
const result = await resolvePreferredNodePath({
env: {},
@@ -197,7 +204,7 @@ describe("resolvePreferredNodePath", () => {
it("ignores execPath when it is not node", async () => {
mockNodePathPresent(darwinNode);
const execFile = vi.fn().mockResolvedValue({ stdout: "22.19.0\n", stderr: "" });
const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.3"));
const result = await resolvePreferredNodePath({
env: {},
@@ -209,16 +216,18 @@ describe("resolvePreferredNodePath", () => {
expect(result).toBe(darwinNode);
expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledWith(darwinNode, ["-p", "process.versions.node"], {
encoding: "utf8",
});
expect(execFile).toHaveBeenCalledWith(
darwinNode,
["-e", expect.stringContaining("SELECT sqlite_version() AS version")],
{ encoding: "utf8" },
);
});
it("uses system node when it meets the minimum version", async () => {
mockNodePathPresent(darwinNode);
// Node 22.19.0+ is the minimum required version
const execFile = vi.fn().mockResolvedValue({ stdout: "22.19.0\n", stderr: "" });
// Node 22.22.3+ is the minimum required version
const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.3"));
const result = await resolvePreferredNodePath({
env: {},
@@ -235,8 +244,8 @@ describe("resolvePreferredNodePath", () => {
it("skips system node when it is too old", async () => {
mockNodePathPresent(darwinNode);
// Node 22.18.x is below minimum 22.19.0
const execFile = vi.fn().mockResolvedValue({ stdout: "22.18.0\n", stderr: "" });
// Node 22.22.2 is below minimum 22.22.3
const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.2", null));
const result = await resolvePreferredNodePath({
env: {},
@@ -250,6 +259,44 @@ describe("resolvePreferredNodePath", () => {
expect(execFile).toHaveBeenCalledTimes(1);
});
it("keeps a safe version-manager runtime when system SQLite is unsafe", async () => {
mockNodePathPresent(linuxSystemNode);
const execFile = vi
.fn()
.mockResolvedValueOnce(nodeRuntime("24.15.0", "3.51.3"))
.mockResolvedValueOnce(nodeRuntime("24.17.0", "3.51.2"));
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "linux",
execFile,
execPath: nvmNode,
});
expect(result).toBe(nvmNode);
});
it("falls back to safe system SQLite when the current runtime is unsafe", async () => {
mockNodePathPresent(linuxSystemNode);
const execFile = vi
.fn()
.mockResolvedValueOnce(nodeRuntime("24.17.0", "3.51.2"))
.mockResolvedValueOnce(nodeRuntime("24.15.0", "3.51.3"));
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "linux",
execFile,
execPath: nvmNode,
});
expect(result).toBe(linuxSystemNode);
});
it("returns undefined when no system node is found", async () => {
fsMocks.access.mockRejectedValue(new Error("missing"));
@@ -271,41 +318,41 @@ describe("resolveStableNodePath", () => {
it("resolves Homebrew Cellar path to opt symlink", async () => {
mockNodePathPresent("/opt/homebrew/opt/node/bin/node");
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.7.0/bin/node");
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.9.0/bin/node");
expect(result).toBe("/opt/homebrew/opt/node/bin/node");
});
it("falls back to bin symlink for default node formula", async () => {
mockNodePathPresent("/opt/homebrew/bin/node");
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.7.0/bin/node");
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.9.0/bin/node");
expect(result).toBe("/opt/homebrew/bin/node");
});
it("resolves Intel Mac Cellar path to opt symlink", async () => {
mockNodePathPresent("/usr/local/opt/node/bin/node");
const result = await resolveStableNodePath("/usr/local/Cellar/node/25.7.0/bin/node");
const result = await resolveStableNodePath("/usr/local/Cellar/node/25.9.0/bin/node");
expect(result).toBe("/usr/local/opt/node/bin/node");
});
it("resolves versioned node@22 formula to opt symlink", async () => {
mockNodePathPresent("/opt/homebrew/opt/node@22/bin/node");
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node@22/22.19.0/bin/node");
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node@22/22.22.3/bin/node");
expect(result).toBe("/opt/homebrew/opt/node@22/bin/node");
});
it("returns original path when no stable symlink exists", async () => {
fsMocks.access.mockRejectedValue(new Error("missing"));
const cellarPath = "/opt/homebrew/Cellar/node/25.7.0/bin/node";
const cellarPath = "/opt/homebrew/Cellar/node/25.9.0/bin/node";
const result = await resolveStableNodePath(cellarPath);
expect(result).toBe(cellarPath);
});
it("returns non-Cellar paths unchanged", async () => {
const fnmPath = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node";
const fnmPath = "/Users/test/.fnm/node-versions/v24.15.0/installation/bin/node";
const result = await resolveStableNodePath(fnmPath);
expect(result).toBe(fnmPath);
});
@@ -318,11 +365,11 @@ describe("resolveStableNodePath", () => {
describe("resolvePreferredNodePath — Homebrew Cellar", () => {
it("resolves Cellar execPath to stable Homebrew symlink", async () => {
const cellarNode = "/opt/homebrew/Cellar/node/25.7.0/bin/node";
const cellarNode = "/opt/homebrew/Cellar/node/25.9.0/bin/node";
const stableNode = "/opt/homebrew/opt/node/bin/node";
mockNodePathPresent(stableNode);
const execFile = vi.fn().mockResolvedValue({ stdout: "25.7.0\n", stderr: "" });
const execFile = vi.fn().mockResolvedValue(nodeRuntime("25.9.0"));
const result = await resolvePreferredNodePath({
env: {},
@@ -342,8 +389,8 @@ describe("resolveSystemNodeInfo", () => {
it("returns supported info when version is new enough", async () => {
mockNodePathPresent(darwinNode);
// Node 22.19.0+ is the minimum required version
const execFile = vi.fn().mockResolvedValue({ stdout: "22.19.0\n", stderr: "" });
// Node 22.22.3+ is the minimum required version
const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.3"));
const result = await resolveSystemNodeInfo({
env: {},
@@ -353,7 +400,8 @@ describe("resolveSystemNodeInfo", () => {
expect(result).toEqual({
path: darwinNode,
version: "22.19.0",
sqliteVersion: "3.51.3",
version: "22.22.3",
supported: true,
});
});
@@ -371,8 +419,8 @@ describe("resolveSystemNodeInfo", () => {
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "22.19.0\n", stderr: "" });
.mockResolvedValueOnce(nodeRuntime("18.0.0", null))
.mockResolvedValueOnce(nodeRuntime("22.22.3"));
const result = await resolveSystemNodeInfo({
env: {},
@@ -382,7 +430,8 @@ describe("resolveSystemNodeInfo", () => {
expect(result).toEqual({
path: homebrewOptNode,
version: "22.19.0",
sqliteVersion: "3.51.3",
version: "22.22.3",
supported: true,
});
});
@@ -395,7 +444,7 @@ describe("resolveSystemNodeInfo", () => {
[homebrewOptNode]: homebrewOptNode,
});
const execFile = vi.fn().mockResolvedValue({ stdout: "24.14.1\n", stderr: "" });
const execFile = vi.fn().mockResolvedValue(nodeRuntime("24.15.0"));
const result = await resolveSystemNodeInfo({
env: {},
@@ -405,13 +454,16 @@ describe("resolveSystemNodeInfo", () => {
expect(result).toEqual({
path: homebrewOptNode,
version: "24.14.1",
sqliteVersion: "3.51.3",
version: "24.15.0",
supported: true,
});
expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledWith(homebrewOptNode, ["-p", "process.versions.node"], {
encoding: "utf8",
});
expect(execFile).toHaveBeenCalledWith(
homebrewOptNode,
["-e", expect.stringContaining("SELECT sqlite_version() AS version")],
{ encoding: "utf8" },
);
});
it("returns null when every system-node candidate resolves into a version manager", async () => {
@@ -436,6 +488,7 @@ describe("resolveSystemNodeInfo", () => {
const warning = renderSystemNodeWarning(
{
path: darwinNode,
sqliteVersion: null,
version: "18.19.0",
supported: false,
},
@@ -446,11 +499,23 @@ describe("resolveSystemNodeInfo", () => {
expect(warning).toContain(darwinNode);
});
it("renders a WAL safety warning for supported Node with unsafe SQLite", () => {
const warning = renderSystemNodeWarning({
path: darwinNode,
sqliteVersion: "3.51.2",
version: "24.17.0",
supported: false,
});
expect(warning).toContain("uses SQLite 3.51.2");
expect(warning).toContain("not WAL-reset-safe");
});
it("uses validated custom Program Files roots on Windows", async () => {
const customNode = "D:\\Programs\\nodejs\\node.exe";
mockNodePathPresent(customNode);
const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" });
const execFile = vi.fn().mockResolvedValue(nodeRuntime("24.15.0"));
const result = await resolveSystemNodeInfo({
env: {
ProgramFiles: "D:\\Programs",
@@ -468,7 +533,7 @@ describe("resolveSystemNodeInfo", () => {
const x86Node = "E:\\Programs (x86)\\nodejs\\node.exe";
mockNodePathPresent(preferredNode, x86Node);
const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" });
const execFile = vi.fn().mockResolvedValue(nodeRuntime("24.15.0"));
const result = await resolveSystemNodeInfo({
env: {
ProgramFiles: "E:\\Programs (x86)",
+51 -12
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { promisify } from "node:util";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { isSupportedNodeVersion } from "../infra/runtime-guard.js";
import { isSqliteWalResetSafeVersion } from "../infra/sqlite-runtime-version.js";
import { resolveStableNodePath } from "../infra/stable-node-path.js";
import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js";
@@ -80,23 +81,56 @@ type ExecFileAsync = (
const execFileAsync = promisify(execFile) as unknown as ExecFileAsync;
async function resolveNodeVersion(
const NODE_RUNTIME_PROBE = String.raw`
let sqliteVersion = null;
try {
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(":memory:");
try {
sqliteVersion = db.prepare("SELECT sqlite_version() AS version").get()?.version ?? null;
} finally {
db.close();
}
} catch {}
process.stdout.write(JSON.stringify({ nodeVersion: process.versions.node, sqliteVersion }));
`;
type NodeRuntimeInfo = {
nodeVersion: string | null;
sqliteVersion: string | null;
supported: boolean;
};
async function resolveNodeRuntimeInfo(
nodePath: string,
execFileImpl: ExecFileAsync,
): Promise<string | null> {
): Promise<NodeRuntimeInfo> {
try {
const { stdout } = await execFileImpl(nodePath, ["-p", "process.versions.node"], {
const { stdout } = await execFileImpl(nodePath, ["-e", NODE_RUNTIME_PROBE], {
encoding: "utf8",
});
const value = stdout.trim();
return value ? value : null;
const parsed = JSON.parse(stdout) as {
nodeVersion?: unknown;
sqliteVersion?: unknown;
};
const nodeVersion = typeof parsed.nodeVersion === "string" ? parsed.nodeVersion : null;
const sqliteVersion = typeof parsed.sqliteVersion === "string" ? parsed.sqliteVersion : null;
return {
nodeVersion,
sqliteVersion,
supported:
isSupportedNodeVersion(nodeVersion) &&
sqliteVersion !== null &&
isSqliteWalResetSafeVersion(sqliteVersion),
};
} catch {
return null;
return { nodeVersion: null, sqliteVersion: null, supported: false };
}
}
type SystemNodeInfo = {
path: string;
sqliteVersion: string | null;
version: string | null;
supported: boolean;
};
@@ -172,11 +206,12 @@ export async function resolveSystemNodeInfo(params: {
if (await isVersionManagedRealNodePath(systemNode, platform)) {
continue;
}
const version = await resolveNodeVersion(systemNode, execFileImpl);
const runtime = await resolveNodeRuntimeInfo(systemNode, execFileImpl);
const info = {
path: systemNode,
version,
supported: isSupportedNodeVersion(version),
sqliteVersion: runtime.sqliteVersion,
version: runtime.nodeVersion,
supported: runtime.supported,
};
if (info.supported) {
return info;
@@ -196,7 +231,11 @@ export function renderSystemNodeWarning(
}
const versionLabel = systemNode.version ?? "unknown";
const selectedLabel = selectedNodePath ? ` Using ${selectedNodePath} for the daemon.` : "";
return `System Node ${versionLabel} at ${systemNode.path} is outside the supported range.${selectedLabel} Install Node 24 (recommended) or Node 22 LTS from nodejs.org or Homebrew.`;
if (isSupportedNodeVersion(systemNode.version)) {
const sqliteLabel = systemNode.sqliteVersion ?? "unknown";
return `System Node ${versionLabel} at ${systemNode.path} uses SQLite ${sqliteLabel}, which is not WAL-reset-safe.${selectedLabel} Install Node 24.15+ (recommended) or Node 22.22.3+ from nodejs.org or Homebrew.`;
}
return `System Node ${versionLabel} at ${systemNode.path} is outside the supported range.${selectedLabel} Install Node 24.15+ (recommended) or Node 22.22.3+ from nodejs.org or Homebrew.`;
}
/** Resolves the Node binary the daemon should use for a node runtime. */
export async function resolvePreferredNodePath(params: {
@@ -214,8 +253,8 @@ export async function resolvePreferredNodePath(params: {
const currentExecPath = params.execPath ?? process.execPath;
const execFileImpl = params.execFile ?? execFileAsync;
if (currentExecPath && isNodeExecPath(currentExecPath, platform)) {
const version = await resolveNodeVersion(currentExecPath, execFileImpl);
if (isSupportedNodeVersion(version)) {
const runtime = await resolveNodeRuntimeInfo(currentExecPath, execFileImpl);
if (runtime.supported) {
const stableCurrentPath = await resolveStableNodePath(currentExecPath);
if (!isVersionManagedNodePath(currentExecPath, platform)) {
return stableCurrentPath;
+3
View File
@@ -97,6 +97,9 @@ describe("auditGatewayServiceConfig", () => {
},
});
expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayRuntimeBun)).toBe(true);
expect(
audit.issues.find((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun)?.message,
).toContain("runtime state requires node:sqlite");
});
it("flags version-managed node paths", async () => {
+2 -2
View File
@@ -562,7 +562,7 @@ async function auditGatewayRuntime(
if (isBunRuntime(execPath)) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeBun,
message: "Gateway service uses Bun; Bun is incompatible with WhatsApp + Telegram channels.",
message: "Gateway service uses Bun; OpenClaw runtime state requires node:sqlite.",
detail: execPath,
level: "recommended",
});
@@ -586,7 +586,7 @@ async function auditGatewayRuntime(
issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing,
message:
"System Node 22 LTS (22.19+) or Node 24 not found; install it before migrating away from version managers.",
"System Node 22 LTS (22.22.3+) or Node 24.15+ not found; install it before migrating away from version managers.",
level: "recommended",
});
}
+2
View File
@@ -69,6 +69,8 @@ if (
ensureOpenClawExecMarkerOnProcess();
installProcessWarningFilter();
normalizeEnv();
const { assertSupportedRuntime } = await import("./infra/runtime-guard.js");
assertSupportedRuntime();
enableOpenClawCompileCache({
installRoot,
@@ -199,7 +199,7 @@ describe("bootstrapWorker", () => {
const runner = fakeRunner([
result({
code: 45,
stderr: "OPENCLAW_WORKER_NODE_UNSUPPORTED: v22.18.0\n",
stderr: "OPENCLAW_WORKER_NODE_UNSUPPORTED: v24.14.1\n",
}),
]);
@@ -208,9 +208,10 @@ describe("bootstrapWorker", () => {
{ ssh: SSH, artifact: BUNDLE },
{ resolveIdentity, runCommand: runner.runCommand },
),
).rejects.toThrow("Node 22.19+, 23.11+, or 24+");
).rejects.toThrow("Node 22.22.3+, 24.15.0+, or 25.9.0+ with WAL-reset-safe SQLite");
expect(runner.calls).toHaveLength(1);
expect(runner.calls[0]?.options.input).toContain("process.versions.node");
expect(runner.calls[0]?.options.input).toContain("SELECT sqlite_version() AS version");
});
it("installs only the exact npm package without transferring a tarball", async () => {
+11 -11
View File
@@ -39,15 +39,15 @@ const BUNDLE_HASH_PATTERN = /^[a-f0-9]{64}$/u;
const NPM_INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]{86}==$/u;
// Keep these boundaries aligned with package.json engines.node and infra/runtime-guard.ts.
const NODE_VERSION_CHECK_JS = String.raw`const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(process.versions.node);
if (!match) process.exit(1);
const major = Number(match[1]);
const minor = Number(match[2]);
const supported =
(major === 22 && minor >= 19) ||
(major === 23 && minor >= 11) ||
major >= 24;
process.exit(supported ? 0 : 1);`;
const NODE_RUNTIME_CHECK_JS = String.raw`const parse = (value) => /^(\d+)\.(\d+)\.(\d+)$/.exec(value)?.slice(1).map(Number); const atLeast = (version, floor) => version[0] > floor[0] || (version[0] === floor[0] && (version[1] > floor[1] || (version[1] === floor[1] && version[2] >= floor[2])));
const node = parse(process.versions.node); if (!node) process.exit(1);
const nodeSafe = (node[0] === 22 && atLeast(node, [22, 22, 3])) || (node[0] === 24 && atLeast(node, [24, 15, 0])) || (node[0] === 25 && atLeast(node, [25, 9, 0])) || node[0] >= 26;
if (!nodeSafe) process.exit(1);
try { const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(":memory:");
const sqlite = parse(String(db.prepare("SELECT sqlite_version() AS version").get()?.version ?? ""));
db.close(); if (!sqlite) process.exit(1);
const sqliteSafe = atLeast(sqlite, [3, 51, 3]) || (sqlite[0] === 3 && ((sqlite[1] === 50 && sqlite[2] >= 7) || (sqlite[1] === 44 && sqlite[2] >= 6)));
process.exit(sqliteSafe ? 0 : 1); } catch { process.exit(1); }`;
const RECEIPT_MATCH_JS = String.raw`const fs = require("node:fs");
try {
@@ -248,7 +248,7 @@ if ! command -v node >/dev/null 2>&1; then
exit ${NODE_MISSING_EXIT_CODE}
fi
if ! node -e '${NODE_VERSION_CHECK_JS}'; then
if ! node -e '${NODE_RUNTIME_CHECK_JS}'; then
printf '%s: ' '${NODE_UNSUPPORTED_MARKER}' >&2
node --version >&2 || true
exit ${NODE_UNSUPPORTED_EXIT_CODE}
@@ -651,7 +651,7 @@ function parsePreflight(
result.stdout.includes(NODE_UNSUPPORTED_MARKER)
) {
throw new Error(
"Worker bootstrap requires Node 22.19+, 23.11+, or 24+ on the leased host; install a supported Node runtime in the provider setup phase and retry",
"Worker bootstrap requires Node 22.22.3+, 24.15.0+, or 25.9.0+ with WAL-reset-safe SQLite on the leased host; install a supported Node runtime in the provider setup phase and retry",
);
}
if (!isSuccess(result)) {
+51
View File
@@ -0,0 +1,51 @@
// Covers the SQLite WAL-reset corruption safety floor.
import { DatabaseSync, type StatementSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const originalPrepare = Reflect.get(DatabaseSync.prototype, "prepare") as DatabaseSync["prepare"];
async function loadNodeSqliteWithVersion(version: string) {
vi.spyOn(DatabaseSync.prototype, "prepare").mockImplementation(
function (this: DatabaseSync, sql) {
if (sql === "SELECT sqlite_version() AS version") {
return {
get: () => ({ version }),
} as unknown as StatementSync;
}
return originalPrepare.call(this, sql);
},
);
return await import("./node-sqlite.js");
}
describe("node SQLite safety", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each(["3.51.3", "3.51.4", "3.52.0", "4.0.0", "3.50.7", "3.50.8", "3.44.6"])(
"accepts patched SQLite %s",
async (version) => {
const { requireNodeSqlite } = await loadNodeSqliteWithVersion(version);
expect(() => requireNodeSqlite()).not.toThrow();
},
);
it.each(["3.51.2", "3.51.0", "3.50.6", "3.49.1", "3.44.5", "invalid", "3.51"])(
"rejects vulnerable or unknown SQLite %s",
async (version) => {
const { requireNodeSqlite } = await loadNodeSqliteWithVersion(version);
expect(() => requireNodeSqlite()).toThrow(`embeds SQLite ${version}, which is affected`);
},
);
it("accepts the SQLite build embedded in the supported test runtime", () => {
return import("./node-sqlite.js").then(({ requireNodeSqlite }) => {
expect(() => requireNodeSqlite()).not.toThrow();
});
});
});
+38 -5
View File
@@ -1,9 +1,41 @@
// Loads node:sqlite with OpenClaw warning handling.
import { createRequire } from "node:module";
import { formatErrorMessage } from "./errors.js";
import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js";
import { installProcessWarningFilter } from "./warning-filter.js";
const require = createRequire(import.meta.url);
let validatedSqliteModule: typeof import("node:sqlite") | undefined;
function assertSqliteWalResetSafeVersion(version: string, nodeVersion: string): void {
if (isSqliteWalResetSafeVersion(version)) {
return;
}
throw new Error(
`OpenClaw requires SQLite 3.51.3+ (or patched 3.50.7+/3.44.6+) for WAL safety; ` +
`Node ${nodeVersion} embeds SQLite ${version}, which is affected by the upstream WAL-reset ` +
"database corruption bug. Upgrade to Node 22.22.3+, 24.15.0+, or 25.9.0+ before retrying.",
);
}
function assertSafeSqliteRuntime(sqlite: typeof import("node:sqlite")): void {
if (validatedSqliteModule === sqlite) {
return;
}
// Shared-SQLite Node builds can load a different library than process.versions
// reports, so query the loaded library before callers open real state databases.
const database = new sqlite.DatabaseSync(":memory:");
try {
const row = database.prepare("SELECT sqlite_version() AS version").get() as
| { version?: unknown }
| undefined;
const version = typeof row?.version === "string" ? row.version : "unknown";
assertSqliteWalResetSafeVersion(version, process.versions.node);
validatedSqliteModule = sqlite;
} finally {
database.close();
}
}
// node:sqlite is optional across Node versions, so callers get a clear runtime
// error instead of a low-level module resolution failure.
@@ -11,12 +43,13 @@ const require = createRequire(import.meta.url);
export function requireNodeSqlite(): typeof import("node:sqlite") {
installProcessWarningFilter();
try {
return require("node:sqlite") as typeof import("node:sqlite");
const sqlite = require("node:sqlite") as typeof import("node:sqlite");
assertSafeSqliteRuntime(sqlite);
return sqlite;
} catch (err) {
const message = formatErrorMessage(err);
throw new Error(
`SQLite support is unavailable in this Node runtime (missing node:sqlite). ${message}`,
{ cause: err },
);
throw new Error(`SQLite support is unavailable or unsafe in this Node runtime. ${message}`, {
cause: err,
});
}
}
+63 -25
View File
@@ -16,7 +16,7 @@ describe("runtime-guard", () => {
it("parses semver with or without leading v", () => {
expect(parseSemver("v22.1.3")).toEqual({ major: 22, minor: 1, patch: 3 });
expect(parseSemver("1.3.0")).toEqual({ major: 1, minor: 3, patch: 0 });
expect(parseSemver("22.19.0-beta.1")).toEqual({ major: 22, minor: 19, patch: 0 });
expect(parseSemver("22.22.3-beta.1")).toEqual({ major: 22, minor: 22, patch: 3 });
expect(parseSemver("invalid")).toBeNull();
});
@@ -38,12 +38,18 @@ describe("runtime-guard", () => {
it("validates runtime thresholds", () => {
const nodeOk: RuntimeDetails = {
kind: "node",
version: "22.19.0",
version: "22.22.3",
execPath: "/usr/bin/node",
pathEnv: "/usr/bin",
};
const nodeOld: RuntimeDetails = { ...nodeOk, version: "22.18.0" };
const nodeOld: RuntimeDetails = { ...nodeOk, version: "22.22.2" };
const nodeTooOld: RuntimeDetails = { ...nodeOk, version: "21.9.0" };
const bun: RuntimeDetails = {
kind: "bun",
version: "1.3.13",
execPath: "/usr/bin/bun",
pathEnv: "/usr/bin",
};
const unknown: RuntimeDetails = {
kind: "unknown",
version: null,
@@ -53,37 +59,42 @@ describe("runtime-guard", () => {
expect(runtimeSatisfies(nodeOk)).toBe(true);
expect(runtimeSatisfies(nodeOld)).toBe(false);
expect(runtimeSatisfies(nodeTooOld)).toBe(false);
expect(runtimeSatisfies(bun)).toBe(false);
expect(runtimeSatisfies(unknown)).toBe(false);
expect(isSupportedNodeVersion("22.19.0")).toBe(true);
expect(isSupportedNodeVersion("22.18.9")).toBe(false);
expect(isSupportedNodeVersion("23.7.0")).toBe(false);
expect(isSupportedNodeVersion("23.10.9")).toBe(false);
expect(isSupportedNodeVersion("23.11.0")).toBe(true);
expect(isSupportedNodeVersion("24.0.0")).toBe(true);
expect(isSupportedNodeVersion("22.22.3")).toBe(true);
expect(isSupportedNodeVersion("22.22.2")).toBe(false);
expect(isSupportedNodeVersion("23.11.0")).toBe(false);
expect(isSupportedNodeVersion("24.14.1")).toBe(false);
expect(isSupportedNodeVersion("24.15.0")).toBe(true);
expect(isSupportedNodeVersion("25.8.1")).toBe(false);
expect(isSupportedNodeVersion("25.9.0")).toBe(true);
expect(isSupportedNodeVersion("26.0.0")).toBe(true);
expect(isSupportedNodeVersion(null)).toBe(false);
});
it("parses simple minimum node engine ranges", () => {
expect(parseMinimumNodeEngine(">=22.19.0")).toEqual({ major: 22, minor: 19, patch: 0 });
expect(parseMinimumNodeEngine(">=22.22.3")).toEqual({ major: 22, minor: 22, patch: 3 });
expect(parseMinimumNodeEngine(" >=v24.0.0 ")).toEqual({ major: 24, minor: 0, patch: 0 });
expect(parseMinimumNodeEngine("^22.19.0")).toBeNull();
expect(parseMinimumNodeEngine("^22.22.3")).toBeNull();
});
it("checks node versions against simple engine ranges", () => {
expect(nodeVersionSatisfiesEngine("22.19.0", ">=22.19.0")).toBe(true);
expect(nodeVersionSatisfiesEngine("22.18.9", ">=22.19.0")).toBe(false);
expect(nodeVersionSatisfiesEngine("24.0.0", ">=22.19.0")).toBe(true);
expect(nodeVersionSatisfiesEngine("22.19.0", "^22.19.0")).toBeNull();
expect(nodeVersionSatisfiesEngine("22.22.3", ">=22.22.3")).toBe(true);
expect(nodeVersionSatisfiesEngine("22.22.2", ">=22.22.3")).toBe(false);
expect(nodeVersionSatisfiesEngine("24.15.0", ">=22.22.3")).toBe(true);
expect(nodeVersionSatisfiesEngine("22.22.3", "^22.22.3")).toBeNull();
});
it("checks node versions against the supported engine range", () => {
const engine = ">=22.19.0 <23 || >=23.11.0";
expect(nodeVersionSatisfiesEngine("22.19.0", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine("22.18.9", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("23.7.0", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("23.10.9", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("23.11.0", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine("24.0.0", engine)).toBe(true);
const engine = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0";
expect(nodeVersionSatisfiesEngine("22.22.3", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine("22.22.2", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("23.11.0", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("24.14.1", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("24.15.0", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine("25.8.1", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("25.9.0", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine("26.0.0", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine(null, engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("unknown", engine)).toBe(false);
});
@@ -106,7 +117,7 @@ describe("runtime-guard", () => {
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
[
"openclaw requires Node >=22.19.0 <23 or >=23.11.0.",
"openclaw requires Node >=22.22.3 <23, >=24.15.0 <25, or >=25.9.0.",
"Detected: node 20.0.0 (exec: /usr/bin/node).",
"PATH searched: /usr/bin",
"Install Node: https://nodejs.org/en/download",
@@ -125,13 +136,40 @@ describe("runtime-guard", () => {
const details: RuntimeDetails = {
...detectRuntime(),
kind: "node",
version: "22.19.0",
version: "22.22.3",
execPath: "/usr/bin/node",
};
expect(assertSupportedRuntime(runtime, details)).toBeUndefined();
expect(runtime.exit).not.toHaveBeenCalled();
});
it("rejects Bun because it does not provide node:sqlite", () => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const details: RuntimeDetails = {
kind: "bun",
version: "1.3.14",
execPath: "/usr/bin/bun",
pathEnv: "/usr/bin",
};
expect(() => assertSupportedRuntime(runtime, details)).toThrow("exit");
expect(runtime.error).toHaveBeenCalledWith(
[
"openclaw cannot run under Bun because the runtime does not provide node:sqlite.",
"Detected: bun 1.3.14 (exec: /usr/bin/bun).",
"PATH searched: /usr/bin",
"Install Node: https://nodejs.org/en/download",
"Run OpenClaw with Node; Bun remains supported for installs and package scripts.",
].join("\n"),
);
});
it("reports unknown runtimes with fallback labels", () => {
const runtime = {
log: vi.fn(),
@@ -151,7 +189,7 @@ describe("runtime-guard", () => {
expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(
[
"openclaw requires Node >=22.19.0 <23 or >=23.11.0.",
"openclaw requires Node >=22.22.3 <23, >=24.15.0 <25, or >=25.9.0.",
"Detected: unknown runtime (exec: unknown).",
"PATH searched: (not set)",
"Install Node: https://nodejs.org/en/download",
+45 -29
View File
@@ -3,7 +3,7 @@ import process from "node:process";
import { expectDefined } from "@openclaw/normalization-core";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
type RuntimeKind = "node" | "unknown";
type RuntimeKind = "bun" | "node" | "unknown";
type Semver = {
major: number;
@@ -11,11 +11,11 @@ type Semver = {
patch: number;
};
const MIN_NODE_22: Semver = { major: 22, minor: 19, patch: 0 };
const MIN_NODE_23: Semver = { major: 23, minor: 11, patch: 0 };
const MIN_NODE_22: Semver = { major: 22, minor: 22, patch: 3 };
const MIN_NODE_24: Semver = { major: 24, minor: 15, patch: 0 };
const MIN_NODE_25: Semver = { major: 25, minor: 9, patch: 0 };
const MINIMUM_ENGINE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i;
const DISJUNCTIVE_ENGINE_RE =
/^\s*>=\s*v?(\d+\.\d+\.\d+)\s+<\s*v?(\d+)(?:\.(\d+)\.(\d+))?\s*\|\|\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i;
const ENGINE_CLAUSE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)(?:\s+<\s*v?(\d+(?:\.\d+\.\d+)?))?\s*$/i;
/** Runtime facts included in startup/runtime-version diagnostics. */
export type RuntimeDetails = {
@@ -60,8 +60,9 @@ export function isAtLeast(version: Semver | null, minimum: Semver): boolean {
/** Reads current process runtime metadata for startup support checks. */
export function detectRuntime(): RuntimeDetails {
const kind: RuntimeKind = process.versions?.node ? "node" : "unknown";
const version = process.versions?.node ?? null;
const bunVersion = process.versions?.bun;
const kind: RuntimeKind = bunVersion ? "bun" : process.versions?.node ? "node" : "unknown";
const version = bunVersion ?? process.versions?.node ?? null;
return {
kind,
@@ -88,10 +89,13 @@ export function isSupportedNodeVersion(version: string | null): boolean {
if (parsed.major === MIN_NODE_22.major) {
return isAtLeast(parsed, MIN_NODE_22);
}
if (parsed.major === MIN_NODE_23.major) {
return isAtLeast(parsed, MIN_NODE_23);
if (parsed.major === MIN_NODE_24.major) {
return isAtLeast(parsed, MIN_NODE_24);
}
return parsed.major > MIN_NODE_23.major;
if (parsed.major === MIN_NODE_25.major) {
return isAtLeast(parsed, MIN_NODE_25);
}
return parsed.major > MIN_NODE_25.major;
}
/** Parses simple package `engines.node` ranges of the form `>=x.y.z`. */
@@ -116,30 +120,34 @@ export function nodeVersionSatisfiesEngine(
return isAtLeast(parseSemver(version), minimum);
}
const rangeMatch = engine?.match(DISJUNCTIVE_ENGINE_RE);
if (!rangeMatch) {
if (!engine) {
return null;
}
const parsed = parseSemver(version);
if (!parsed) {
return false;
}
const [, firstMinimumRaw, upperMajorRaw, upperMinorRaw, upperPatchRaw, secondMinimumRaw] =
rangeMatch;
const firstMinimum = parseSemver(firstMinimumRaw ?? null);
const secondMinimum = parseSemver(secondMinimumRaw ?? null);
const upperBound: Semver = {
major: Number.parseInt(upperMajorRaw ?? "", 10),
minor: Number.parseInt(upperMinorRaw ?? "0", 10),
patch: Number.parseInt(upperPatchRaw ?? "0", 10),
};
if (!firstMinimum || !secondMinimum || !Number.isFinite(upperBound.major)) {
return null;
const clauses = engine.split("||");
let satisfied = false;
for (const clause of clauses) {
const match = clause.match(ENGINE_CLAUSE_RE);
if (!match) {
return null;
}
const clauseMinimum = parseSemver(match[1] ?? null);
const upperRaw = match[2];
const upper = upperRaw
? parseSemver(upperRaw.includes(".") ? upperRaw : `${upperRaw}.0.0`)
: null;
if (!clauseMinimum || (upperRaw && !upper)) {
return null;
}
if (isAtLeast(parsed, clauseMinimum) && (!upper || !isAtLeast(parsed, upper))) {
satisfied = true;
}
}
return (
(isAtLeast(parsed, firstMinimum) && !isAtLeast(parsed, upperBound)) ||
isAtLeast(parsed, secondMinimum)
);
return satisfied;
}
/** Exits through the provided runtime when the current Node runtime is unsupported. */
@@ -155,14 +163,22 @@ export function assertSupportedRuntime(
const runtimeLabel =
details.kind === "unknown" ? "unknown runtime" : `${details.kind} ${versionLabel}`;
const execLabel = details.execPath ?? "unknown";
const requirement =
details.kind === "bun"
? "openclaw cannot run under Bun because the runtime does not provide node:sqlite."
: "openclaw requires Node >=22.22.3 <23, >=24.15.0 <25, or >=25.9.0.";
const retryHint =
details.kind === "bun"
? "Run OpenClaw with Node; Bun remains supported for installs and package scripts."
: "Upgrade Node and re-run openclaw.";
runtime.error(
[
"openclaw requires Node >=22.19.0 <23 or >=23.11.0.",
requirement,
`Detected: ${runtimeLabel} (exec: ${execLabel}).`,
`PATH searched: ${details.pathEnv}`,
"Install Node: https://nodejs.org/en/download",
"Upgrade Node and re-run openclaw.",
retryHint,
].join("\n"),
);
runtime.exit(1);
+1 -1
View File
@@ -61,7 +61,7 @@ function runSqliteForeignKeyCheck(database: DatabaseSync, databaseLabel: string)
// table-valued pragma name and make a corrupt database appear clean.
const statement = database.prepare("PRAGMA foreign_key_check;");
statement.setReadBigInts(true);
// OpenClaw's Node >=22.19 floor includes iterate(), added in Node 22.13.
// OpenClaw's Node >=22.22.3 floor includes iterate(), added in Node 22.13.
for (const violation of statement.iterate() as Iterable<SqliteForeignKeyViolation>) {
violationCount += 1;
retainSortedForeignKeyViolation(violations, violation);
+52
View File
@@ -0,0 +1,52 @@
type SqliteVersion = {
major: number;
minor: number;
patch: number;
};
const SQLITE_WAL_RESET_FIXED_VERSION: SqliteVersion = { major: 3, minor: 51, patch: 3 };
const SQLITE_WAL_RESET_BACKPORTS: readonly SqliteVersion[] = [
{ major: 3, minor: 44, patch: 6 },
{ major: 3, minor: 50, patch: 7 },
];
const SQLITE_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/u;
function parseSqliteVersion(value: string): SqliteVersion | null {
const match = SQLITE_VERSION_PATTERN.exec(value.trim());
if (!match) {
return null;
}
const major = Number.parseInt(match[1] ?? "", 10);
const minor = Number.parseInt(match[2] ?? "", 10);
const patch = Number.parseInt(match[3] ?? "", 10);
if (![major, minor, patch].every(Number.isSafeInteger)) {
return null;
}
return { major, minor, patch };
}
function compareSqliteVersions(left: SqliteVersion, right: SqliteVersion): number {
if (left.major !== right.major) {
return left.major - right.major;
}
if (left.minor !== right.minor) {
return left.minor - right.minor;
}
return left.patch - right.patch;
}
export function isSqliteWalResetSafeVersion(value: string): boolean {
const version = parseSqliteVersion(value);
if (!version) {
return false;
}
if (compareSqliteVersions(version, SQLITE_WAL_RESET_FIXED_VERSION) >= 0) {
return true;
}
return SQLITE_WAL_RESET_BACKPORTS.some(
(backport) =>
version.major === backport.major &&
version.minor === backport.minor &&
version.patch >= backport.patch,
);
}
+4 -4
View File
@@ -12,8 +12,8 @@ describe("resolveStableNodePath", () => {
it("prefers the Homebrew opt symlink for default and versioned formulas", async () => {
await withTempDir({ prefix: "openclaw-stable-node-" }, async (prefix) => {
const defaultNode = path.join(prefix, "Cellar", "node", "25.7.0", "bin", "node");
const versionedNode = path.join(prefix, "Cellar", "node@22", "22.19.0", "bin", "node");
const defaultNode = path.join(prefix, "Cellar", "node", "25.9.0", "bin", "node");
const versionedNode = path.join(prefix, "Cellar", "node@22", "22.22.3", "bin", "node");
const optDefault = path.join(prefix, "opt", "node", "bin", "node");
const optVersioned = path.join(prefix, "opt", "node@22", "bin", "node");
@@ -29,8 +29,8 @@ describe("resolveStableNodePath", () => {
it("falls back to the bin symlink for the default formula, otherwise original path", async () => {
await withTempDir({ prefix: "openclaw-stable-node-" }, async (prefix) => {
const defaultNode = path.join(prefix, "Cellar", "node", "25.7.0", "bin", "node");
const versionedNode = path.join(prefix, "Cellar", "node@22", "22.19.0", "bin", "node");
const defaultNode = path.join(prefix, "Cellar", "node", "25.9.0", "bin", "node");
const versionedNode = path.join(prefix, "Cellar", "node@22", "22.22.3", "bin", "node");
const binNode = path.join(prefix, "bin", "node");
await fs.mkdir(path.dirname(binNode), { recursive: true });
+1 -1
View File
@@ -1000,7 +1000,7 @@ export const en = {
daemonRuntime: "Gateway service runtime",
daemonRuntimeNode: "Node (recommended)",
daemonRuntimeNodeHint:
"Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.",
"Required because OpenClaw state uses node:sqlite; Bun cannot run the Gateway.",
editBootstrap: "Edit BOOTSTRAP.md later to change how the agent introduces itself.",
bootstrapHatchMessage: "Wake up, my friend!",
firstTerminalChat: 'The first Terminal chat run will send: "Wake up, my friend!"',
+1 -1
View File
@@ -968,7 +968,7 @@ export const zh_CN = {
dashboardWhenReady: "准备好后运行:{command}",
daemonRuntime: "Gateway 服务运行时",
daemonRuntimeNode: "Node(推荐)",
daemonRuntimeNodeHint: "WhatsApp + Telegram 需要使用。Bun 重新连接时可能造成内存损坏。",
daemonRuntimeNodeHint: "OpenClaw 状态使用 node:sqlite,因此必须使用 NodeBun 无法运行网关。",
editBootstrap: "之后可编辑 BOOTSTRAP.md 来修改 agent 的自我介绍方式。",
bootstrapHatchMessage: "醒醒,我的朋友!",
firstTerminalChat: '第一次终端聊天会发送:"醒醒,我的朋友!"',
+1 -1
View File
@@ -969,7 +969,7 @@ export const zh_TW = {
dashboardWhenReady: "準備好後執行:{command}",
daemonRuntime: "Gateway 服務執行環境",
daemonRuntimeNode: "Node(建議)",
daemonRuntimeNodeHint: "WhatsApp + Telegram 需要使用。Bun 重新連線時可能造成記憶體損壞。",
daemonRuntimeNodeHint: "OpenClaw 狀態使用 node:sqlite,因此必須使用 NodeBun 無法執行閘道。",
editBootstrap: "之後可編輯 BOOTSTRAP.md 來修改 agent 的自我介紹方式。",
bootstrapHatchMessage: "醒醒,我的朋友!",
firstTerminalChat: '第一次終端機聊天會傳送:"醒醒,我的朋友!"',
+2 -2
View File
@@ -4606,7 +4606,7 @@ surfaces:
description: Hosted installer and local-prefix install paths on macOS
- name: Node 24 recommendation
coverageIds: [macos.node-24-recommendation]
description: Node 24 recommendation and Node 22.19+ compatibility floor
description: Node 24.15+ recommendation and WAL-reset-safe Node runtime floor
- name: App-triggered CLI install
coverageIds: [macos.app-triggered-cli-install]
description: App-triggered CLI install and runtime discovery
@@ -5423,7 +5423,7 @@ surfaces:
description: WSL2 and Ubuntu installation requirements.
- name: Node runtime
coverageIds: [wsl2.node-runtime]
description: Node 24 and Node 22.19+ runtime requirements inside WSL2.
description: WAL-reset-safe Node runtime requirements inside WSL2.
- name: Linux install flow inside WSL2
coverageIds: [wsl2.linux-install-flow-inside-wsl2]
description: Linux install and getting-started flow run inside WSL2.
+52 -103
View File
@@ -166,7 +166,7 @@ describe("openclaw launcher", () => {
it("keeps the bootstrap Node range aligned with the package engine", async () => {
const packageJsonRaw = await fs.readFile(path.resolve(process.cwd(), "package.json"), "utf8");
const packageJson = JSON.parse(packageJsonRaw) as { engines?: { node?: string } };
expect(packageJson.engines?.node).toBe(">=22.19.0 <23 || >=23.11.0");
expect(packageJson.engines?.node).toBe(">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0");
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
@@ -176,12 +176,14 @@ describe("openclaw launcher", () => {
);
const cases = [
{ version: "22.18.9", supported: false },
{ version: "22.19.0", supported: true },
{ version: "23.7.0", supported: false },
{ version: "23.10.9", supported: false },
{ version: "23.11.0", supported: true },
{ version: "24.0.0", supported: true },
{ version: "22.22.2", supported: false },
{ version: "22.22.3", supported: true },
{ version: "23.11.0", supported: false },
{ version: "24.14.1", supported: false },
{ version: "24.15.0", supported: true },
{ version: "25.8.1", supported: false },
{ version: "25.9.0", supported: true },
{ version: "26.0.0", supported: true },
] as const;
for (const testCase of cases) {
@@ -220,12 +222,46 @@ describe("openclaw launcher", () => {
} else {
expect(result.status, testCase.version).toBe(1);
expect(result.stderr, testCase.version).toContain(
`openclaw: Node.js >=22.19.0 <23 or >=23.11.0 is required (current: v${testCase.version}).`,
`openclaw: Node.js >=22.22.3 <23, >=24.15.0 <25, or >=25.9.0 is required (current: v${testCase.version}).`,
);
}
}
});
it("rejects Bun even when its Node compatibility version is new enough", async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
path.join(fixtureRoot, "dist", "entry.js"),
'process.stdout.write("unexpected-bun-runtime\\n");\n',
"utf8",
);
const mockRuntime = path.join(fixtureRoot, "mock-bun-runtime.mjs");
await fs.writeFile(
mockRuntime,
[
"Object.defineProperty(process.versions, 'bun', { value: '1.3.14' });",
"Object.defineProperty(process.versions, 'node', { value: '24.3.0' });",
].join("\n"),
"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(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain(
"the Bun runtime is unsupported because OpenClaw requires node:sqlite",
);
});
it("surfaces transitive entry import failures instead of masking them as missing dist", async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
@@ -382,12 +418,12 @@ describe("openclaw launcher", () => {
});
it.runIf(process.env.OPENCLAW_TEST_BUN_LAUNCHER === "1" && hasBunRuntime())(
"falls through Bun optional warning filter misses",
"rejects the real Bun runtime before loading the CLI",
async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
path.join(fixtureRoot, "dist", "entry.js"),
"process.stdout.write('bun entry ok\\n');\n",
"process.stdout.write('unexpected bun entry\\n');\n",
"utf8",
);
@@ -401,39 +437,11 @@ describe("openclaw launcher", () => {
},
);
expect(result.status).toBe(0);
expect(result.stdout).toBe("bun entry ok\n");
},
);
it.runIf(process.env.OPENCLAW_TEST_BUN_LAUNCHER === "1" && hasBunRuntime())(
"surfaces Bun transitive entry misses with the same raw specifier",
async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile(
path.join(fixtureRoot, "dist", "warning-filter.js"),
"export function installProcessWarningFilter() {}\n",
"utf8",
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain(
"the Bun runtime is unsupported because OpenClaw requires node:sqlite",
);
await fs.writeFile(
path.join(fixtureRoot, "dist", "entry.js"),
'import "./dist/entry.js";\n',
"utf8",
);
const result = spawnSync(
process.env.BUN_BIN ?? "bun",
[path.join(fixtureRoot, "openclaw.mjs"), "--help"],
{
cwd: fixtureRoot,
env: launcherEnv(),
encoding: "utf8",
},
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("Cannot find module './dist/entry.js'");
expect(result.stderr).not.toContain("missing dist/entry.(m)js");
},
);
@@ -999,70 +1007,11 @@ describe("openclaw launcher", () => {
expect(result.stdout).not.toContain(path.join(runCwd, "openclaw"));
});
it("skips compile cache for Windows packaged launchers on early Node 24.x", async () => {
for (const nodeVersion of ["24.1.0", "24.14.0"]) {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
const tmpRoot = makeTempDir(fixtureRoots, "openclaw-launcher-tmp-");
const mockRuntime = await addLauncherRuntimeMock(fixtureRoot, {
nodeVersion,
platform: "win32",
});
await addCompileCacheProbe(fixtureRoot);
const result = spawnSync(
process.execPath,
["--import", pathToFileURL(mockRuntime).href, path.join(fixtureRoot, "openclaw.mjs")],
{
cwd: fixtureRoot,
env: launcherEnv({
TMP: tmpRoot,
TEMP: tmpRoot,
TMPDIR: tmpRoot,
}),
encoding: "utf8",
},
);
expect(result.status).toBe(0);
expect(result.stdout).toBe("cache:disabled;respawn:0");
}
});
it("respawns Windows early Node 24 packaged launchers without inherited NODE_COMPILE_CACHE", async () => {
for (const nodeVersion of ["24.1.0", "24.14.0"]) {
const fixtureRoot = await makeLauncherFixture(fixtureRoots);
const tmpRoot = makeTempDir(fixtureRoots, "openclaw-launcher-tmp-");
const mockRuntime = await addLauncherRuntimeMock(fixtureRoot, {
nodeVersion,
platform: "win32",
});
await addCompileCacheProbe(fixtureRoot);
const result = spawnSync(
process.execPath,
["--import", pathToFileURL(mockRuntime).href, path.join(fixtureRoot, "openclaw.mjs")],
{
cwd: fixtureRoot,
env: launcherEnv({
NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"),
TMP: tmpRoot,
TEMP: tmpRoot,
TMPDIR: tmpRoot,
}),
encoding: "utf8",
},
);
expect(result.status).toBe(0);
expect(result.stdout).toBe("cache:disabled;respawn:1");
}
});
it("keeps compile cache enabled for unaffected packaged launcher runtimes", async () => {
const cases: Array<{ nodeVersion: string; platform: NodeJS.Platform }> = [
{ nodeVersion: "24.15.0", platform: "win32" },
{ nodeVersion: "24.1.0", platform: "linux" },
{ nodeVersion: "24.14.0", platform: "darwin" },
{ nodeVersion: "22.22.3", platform: "linux" },
{ nodeVersion: "25.9.0", platform: "darwin" },
];
for (const runtime of cases) {
@@ -53,7 +53,7 @@ describe("check-cli-startup-memory", () => {
});
it("guards packaged plugin listing startup memory", () => {
expect(testing.resolveDefaultLimitsMb("linux").pluginsList).toBe(350);
expect(testing.resolveDefaultLimitsMb("linux").pluginsList).toBe(400);
expect(testing.resolveDefaultLimitsMb("darwin").pluginsList).toBeGreaterThan(350);
expect(testing.cases).toContainEqual(
expect.objectContaining({
+1 -1
View File
@@ -2172,7 +2172,7 @@ describe("ci workflow guards", () => {
);
expect(startupMemoryStep.env.OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB).toBe(
"${{ runner.environment == 'github-hosted' && '425' || '350' }}",
"${{ runner.environment == 'github-hosted' && '425' || '400' }}",
);
});
+111 -48
View File
@@ -47,40 +47,109 @@ describe("install-cli.sh", () => {
expect(script).toContain('cleanup_legacy_submodules "$repo_dir"');
});
it("accepts only Node versions with the required SQLite statement API", () => {
it("accepts only Node versions with the WAL-reset corruption fix", () => {
expect(script).toContain("SELECT sqlite_version() AS version");
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
set +e
for version in 22.18.9 22.19.0 23.7.0 23.10.9 23.11.0 24.0.0; do
for version in 22.22.2 22.22.3 23.11.0 24.14.1 24.15.0 25.8.1 25.9.0 26.0.0; do
node_version_is_supported "$version"
printf '%s=%s\n' "$version" "$?"
done
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("22.18.9=1");
expect(result.stdout).toContain("22.19.0=0");
expect(result.stdout).toContain("23.7.0=1");
expect(result.stdout).toContain("23.10.9=1");
expect(result.stdout).toContain("23.11.0=0");
expect(result.stdout).toContain("24.0.0=0");
expect(result.stdout).toContain("22.22.2=1");
expect(result.stdout).toContain("22.22.3=0");
expect(result.stdout).toContain("23.11.0=1");
expect(result.stdout).toContain("24.14.1=1");
expect(result.stdout).toContain("24.15.0=0");
expect(result.stdout).toContain("25.8.1=1");
expect(result.stdout).toContain("25.9.0=0");
expect(result.stdout).toContain("26.0.0=0");
});
it("rejects an explicitly requested incompatible Node 23 release", () => {
it("reuses the minimum supported runtime unless a newer version was explicitly requested", () => {
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NODE_VERSION=23.7.0
NODE_VERSION=24.15.0
NODE_VERSION_REQUESTED=0
printf 'default=%s\n' "$(required_node_version)"
NODE_VERSION_REQUESTED=1
printf 'requested=%s\n' "$(required_node_version)"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("default=22.22.3");
expect(result.stdout).toContain("requested=24.15.0");
});
it("uses the patched Node 22 line for Linux ARMv7 by default", () => {
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NODE_VERSION=24.15.0
NODE_VERSION_REQUESTED=0
select_node_version_for_platform linux armv7l
printf 'selected=%s\n' "$NODE_VERSION"
`);
expect(result.status).toBe(0);
expect(result.stdout).toContain("selected=22.22.3");
expect(script).toContain('armv7|armv7l) echo "armv7l"');
});
it("selects the ARMv7 runtime before constructing PATH", () => {
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
os_detect() { printf 'linux\n'; }
arch_detect() { printf 'armv7l\n'; }
install_node() {
printf 'selected=%s\n' "$NODE_VERSION"
printf 'first-path=%s\n' "\${PATH%%:*}"
return 17
}
main
`);
expect(result.status).toBe(17);
expect(result.stdout).toContain("selected=22.22.3");
expect(result.stdout).toContain("first-path=");
expect(result.stdout).toContain("/tools/node-v22.22.3/bin");
expect(result.stdout).not.toContain("/tools/node-v24.15.0/bin");
});
it("fails early for unavailable Node 24 Linux ARMv7 downloads", () => {
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NODE_VERSION=24.15.0
NODE_VERSION_REQUESTED=1
select_node_version_for_platform linux armv7l
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"Linux ARMv7 requires Node 22.22.3+ because official Node 24+ binaries are unavailable",
);
});
it("rejects an explicitly requested vulnerable Node release", () => {
const result = runInstallCliShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
NODE_VERSION=24.14.1
install_node
`);
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"Node 23.7.0 is unsupported; use Node 22.19+, Node 23.11+, or Node 24+.",
"Node 24.14.1 is unsupported; use Node 22.22.3+, Node 24.15.0+, or Node 25.9.0+.",
);
expect(result.stdout).not.toContain("Installing Node 23.7.0");
expect(result.stdout).not.toContain("Installing Node 24.14.1");
});
it("rejects installer options with missing values", () => {
@@ -274,7 +343,7 @@ describe("install-cli.sh", () => {
[
"#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.2\\n'",
" printf 'v22.22.3\\n'",
" exit 0",
"fi",
'if [[ "${1:-}" == "-e" ]]; then',
@@ -302,7 +371,6 @@ describe("install-cli.sh", () => {
"is_root() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`,
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
"NODE_VERSION=22.22.0",
"install_node",
].join("\n"),
{
@@ -314,8 +382,8 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(0);
expect(result.stdout).not.toContain("Installing Node via apk");
expect(() => readFileSync(apkLog, "utf8")).toThrow();
const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm");
const nodeLink = join(prefix, "tools", "node-v24.15.0", "bin", "node");
const npmLink = join(prefix, "tools", "node-v24.15.0", "bin", "npm");
expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(nodeLink)).toBe(fakeNode);
expect(readlinkSync(npmLink)).toBe(fakeNpm);
@@ -330,7 +398,7 @@ describe("install-cli.sh", () => {
const bin = join(tmp, "bin");
const oldBin = join(tmp, "old-bin");
const prefix = join(tmp, "prefix");
const nodePrefixBin = join(prefix, "tools", "node-v22.22.0", "bin");
const nodePrefixBin = join(prefix, "tools", "node-v22.22.3", "bin");
const apkLog = join(tmp, "apk.log");
const fakeApk = join(bin, "apk");
const fakeNode = join(bin, "node");
@@ -352,7 +420,7 @@ describe("install-cli.sh", () => {
[
"#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.0\\n'",
" printf 'v22.22.3\\n'",
" exit 0",
"fi",
'if [[ "${1:-}" == "-e" ]]; then',
@@ -367,7 +435,7 @@ describe("install-cli.sh", () => {
[
"#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.2\\n'",
" printf 'v22.22.3\\n'",
" exit 0",
"fi",
'if [[ "${1:-}" == "-e" ]]; then',
@@ -413,8 +481,7 @@ describe("install-cli.sh", () => {
"is_musl_linux() { return 0; }",
"is_root() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.22.0",
"NODE_VERSION_REQUESTED=1",
"NODE_VERSION=22.22.3",
"install_node",
].join("\n"),
{
@@ -426,8 +493,8 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(0);
expect(result.stdout).not.toContain("Installing Node via apk");
expect(() => readFileSync(apkLog, "utf8")).toThrow();
const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm");
const nodeLink = join(prefix, "tools", "node-v22.22.3", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.3", "bin", "npm");
expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(nodeLink)).toBe(fakeNode);
expect(readlinkSync(npmLink)).toBe(fakeNpm);
@@ -464,7 +531,7 @@ describe("install-cli.sh", () => {
"#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then',
' if [[ -f "$NODE_STATE" ]]; then',
" printf 'v22.22.2\\n'",
" printf 'v22.22.3\\n'",
" else",
" printf 'v18.20.0\\n'",
" fi",
@@ -496,8 +563,7 @@ describe("install-cli.sh", () => {
"is_root() { return 0; }",
`PREFIX=${JSON.stringify(prefix)}`,
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
"NODE_VERSION=22.22.0",
"NODE_VERSION_REQUESTED=1",
"NODE_VERSION=22.22.3",
"install_node",
].join("\n"),
{
@@ -510,8 +576,8 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(0);
expect(result.stdout).toContain("Installing Node via apk");
expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm");
const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm");
const nodeLink = join(prefix, "tools", "node-v22.22.3", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.3", "bin", "npm");
expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(nodeLink)).toBe(fakeNode);
expect(readlinkSync(npmLink)).toBe(fakeNpm);
@@ -569,8 +635,7 @@ describe("install-cli.sh", () => {
"is_root() { return 0; }",
`PREFIX=${JSON.stringify(prefix)}`,
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
"NODE_VERSION=22.22.0",
"NODE_VERSION_REQUESTED=1",
"NODE_VERSION=22.22.3",
"install_node",
].join("\n"),
{
@@ -582,9 +647,9 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(1);
expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm");
expect(result.stdout).toContain(
"Alpine Node package must provide Node >= 22.22.0 with node:sqlite",
"Alpine Node package must provide Node >= 22.22.3 with WAL-reset-safe SQLite",
);
expect(result.stdout).toContain("found v22.18.0");
expect(result.stdout).toContain("found Node v22.18.0, SQLite unavailable");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
@@ -593,7 +658,7 @@ describe("install-cli.sh", () => {
it("replaces cached generic Node runtimes below the runtime floor", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-generic-stale-node-"));
const prefix = join(tmp, "prefix");
const nodePrefixBin = join(prefix, "tools", "node-v22.22.0", "bin");
const nodePrefixBin = join(prefix, "tools", "node-v22.22.3", "bin");
const staleNode = join(nodePrefixBin, "node");
const staleNpm = join(nodePrefixBin, "npm");
const newNode = join(tmp, "new-node");
@@ -621,7 +686,7 @@ describe("install-cli.sh", () => {
[
"#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.0\\n'",
" printf 'v22.22.3\\n'",
" exit 0",
"fi",
'if [[ "${1:-}" == "-e" ]]; then',
@@ -650,7 +715,7 @@ describe("install-cli.sh", () => {
"require_bin() { :; }",
"download_file() {",
' case "$1" in',
" */SHASUMS256.txt) printf 'fixture-sha node-v22.22.0-linux-x64.tar.gz\\n' > \"$2\" ;;",
" */SHASUMS256.txt) printf 'fixture-sha node-v22.22.3-linux-x64.tar.gz\\n' > \"$2\" ;;",
" *) printf 'node tarball fixture\\n' > \"$2\" ;;",
" esac",
"}",
@@ -665,8 +730,7 @@ describe("install-cli.sh", () => {
' cp "$NEW_NPM" "$dest/bin/npm"',
"}",
`PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.22.0",
"NODE_VERSION_REQUESTED=1",
"NODE_VERSION=22.22.3",
"install_node",
].join("\n"),
{
@@ -676,9 +740,9 @@ describe("install-cli.sh", () => {
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("Installing Node 22.22.0 (user-space)");
expect(result.stdout).toContain("Installing Node 22.22.3 (user-space)");
expect(result.stdout).not.toContain('"status":"skip"');
expect(readFileSync(staleNode, "utf8")).toContain("v22.22.0");
expect(readFileSync(staleNode, "utf8")).toContain("v22.22.3");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
@@ -695,7 +759,7 @@ describe("install-cli.sh", () => {
[
"#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.18.0\\n'",
" printf 'v22.22.2\\n'",
" exit 0",
"fi",
'if [[ "${1:-}" == "-e" ]]; then',
@@ -722,7 +786,7 @@ describe("install-cli.sh", () => {
"require_bin() { :; }",
"download_file() {",
' case "$1" in',
" */SHASUMS256.txt) printf 'fixture-sha node-v22.19.0-linux-x64.tar.gz\\n' > \"$2\" ;;",
" */SHASUMS256.txt) printf 'fixture-sha node-v22.22.3-linux-x64.tar.gz\\n' > \"$2\" ;;",
" *) printf 'node tarball fixture\\n' > \"$2\" ;;",
" esac",
"}",
@@ -737,8 +801,7 @@ describe("install-cli.sh", () => {
' cp "$NEW_NPM" "$dest/bin/npm"',
"}",
`PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.19.0",
"NODE_VERSION_REQUESTED=1",
"NODE_VERSION=22.22.3",
"install_node",
].join("\n"),
{
@@ -749,9 +812,9 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(1);
expect(result.stdout).toContain(
"Installed Node 22.19.0 must provide Node >= 22.19.0 with node:sqlite",
"Installed Node 22.22.3 must provide Node >= 22.22.3 with WAL-reset-safe SQLite",
);
expect(result.stdout).toContain("found v22.18.0");
expect(result.stdout).toContain("found Node v22.22.2, SQLite unavailable");
} finally {
rmSync(tmp, { force: true, recursive: true });
}
@@ -777,7 +840,7 @@ describe("install-cli.sh", () => {
`mktemp() { mkdir -p ${JSON.stringify(stagingDir)}; printf '%s\\n' ${JSON.stringify(stagingDir)}; }`,
"download_file() { return 42; }",
`PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.22.0",
"NODE_VERSION=22.22.3",
"install_node",
].join("\n"),
);
@@ -1060,7 +1123,7 @@ describe("install-cli.sh", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-freshness-"));
const prefix = join(tmp, "prefix");
const home = join(tmp, "home");
const nodeBin = join(prefix, "tools/node-v22.22.2/bin");
const nodeBin = join(prefix, "tools/node-v24.15.0/bin");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
@@ -1096,7 +1159,7 @@ describe("install-cli.sh", () => {
const prefix = join(tmp, "prefix");
const home = join(tmp, "home");
const project = join(tmp, "project");
const nodeBin = join(prefix, "tools/node-v22.22.2/bin");
const nodeBin = join(prefix, "tools/node-v24.15.0/bin");
const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true });
+102 -10
View File
@@ -110,12 +110,14 @@ describe("install.ps1 failure handling", () => {
scriptWithoutEntryPoint,
"",
"$cases = @{",
" '22.18.9' = $false",
" '22.19.0' = $true",
" '23.7.0' = $false",
" '23.10.9' = $false",
" '23.11.0' = $true",
" '24.0.0' = $true",
" '22.22.2' = $false",
" '22.22.3' = $true",
" '23.11.0' = $false",
" '24.14.1' = $false",
" '24.15.0' = $true",
" '25.8.1' = $false",
" '25.9.0' = $true",
" '26.0.0' = $true",
"}",
"foreach ($entry in $cases.GetEnumerator()) {",
" $actual = Test-NodeVersionSupported -Version $entry.Key",
@@ -201,6 +203,83 @@ describe("install.ps1 failure handling", () => {
"",
].join("\n"),
},
{
name: "chocolatey-node-upgrade",
source: [
scriptWithoutEntryPoint,
"",
"function Get-Command {",
" [CmdletBinding()]",
" param([string]$Name)",
" if ($Name -eq 'choco') { return $true }",
" return $null",
"}",
"filter Out-Host { }",
"function choco {",
" $script:chocoArgs = $args -join ' '",
" $global:LASTEXITCODE = 0",
" Write-Output 'Chocolatey output'",
"}",
"function Check-Node { return $true }",
"$result = @(Install-Node)",
'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }',
"if ($script:chocoArgs -ne 'upgrade nodejs-lts -y --install-if-not-installed') {",
' throw "Args=$script:chocoArgs"',
"}",
"",
].join("\n"),
},
{
name: "scoop-node-update",
source: [
scriptWithoutEntryPoint,
"",
"function Get-Command {",
" [CmdletBinding()]",
" param([string]$Name)",
" if ($Name -eq 'scoop') { return $true }",
" return $null",
"}",
"filter Out-Host { }",
"$env:Path = 'C:\\session-bin'",
"$script:scoopCalls = @()",
"function scoop {",
" $script:scoopCalls += ($args -join ' ')",
" $global:LASTEXITCODE = 0",
" Write-Output 'Scoop output'",
"}",
"function Check-Node { return $true }",
"$result = @(Install-Node)",
'if ($result.Count -ne 1 -or $result[0] -ne $true) { throw "Install-Node returned $result" }',
"if (($script:scoopCalls -join '|') -ne 'update|install nodejs-lts|update nodejs-lts') {",
" throw \"Calls=$($script:scoopCalls -join '|')\"",
"}",
"if (($env:Path -split ';') -notcontains 'C:\\session-bin') { throw \"Path=$env:Path\" }",
"",
].join("\n"),
},
{
name: "package-manager-node-validation-failure",
source: [
scriptWithoutEntryPoint,
"",
"function Get-Command {",
" [CmdletBinding()]",
" param([string]$Name)",
" if ($Name -eq 'choco') { return $true }",
" return $null",
"}",
"filter Out-Host { }",
"function choco {",
" $global:LASTEXITCODE = 0",
" Write-Output 'Chocolatey output'",
"}",
"function Check-Node { return $false }",
"$result = @(Install-Node)",
'if ($result.Count -ne 1 -or $result[0] -ne $false) { throw "Install-Node returned $result" }',
"",
].join("\n"),
},
{
name: "scriptblock-failure",
source: [
@@ -347,19 +426,32 @@ describe("install.ps1 failure handling", () => {
it("checks the full supported Node version range", () => {
const versionBody = extractFunctionBody(source, "Test-NodeVersionSupported");
const sqliteBody = extractFunctionBody(source, "Test-NodeSqliteSupported");
const checkNodeBody = extractFunctionBody(source, "Check-Node");
expect(versionBody).toContain("$major -eq 22");
expect(versionBody).toContain("$minor -ge 19");
expect(versionBody).toContain("$major -eq 23");
expect(versionBody).toContain("$minor -ge 11");
expect(versionBody).toContain("$major -gt 23");
expect(versionBody).toContain("$patch -ge 3");
expect(versionBody).toContain("$major -eq 24");
expect(versionBody).toContain("$minor -ge 15");
expect(versionBody).toContain("$major -eq 25");
expect(versionBody).toContain("$minor -ge 9");
expect(versionBody).toContain("$major -gt 25");
expect(sqliteBody).toContain("SELECT sqlite_version() AS version");
expect(sqliteBody).toContain("patch >= 3");
expect(checkNodeBody).toContain("Test-NodeVersionSupported -Version $nodeVersion");
expect(checkNodeBody).toContain("Test-NodeSqliteSupported");
expect(source).toContain("Please install Node.js 24.15+ manually:");
});
runIfPowerShell("accepts only supported Node versions", () => {
expectBatchedPowerShellCase("node-versions");
});
runIfPowerShell("upgrades and validates Node installed by Windows package managers", () => {
expectBatchedPowerShellCase("chocolatey-node-upgrade");
expectBatchedPowerShellCase("scoop-node-update");
expectBatchedPowerShellCase("package-manager-node-validation-failure");
});
it("runs npm install through the resolved command with quiet CI defaults", () => {
const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw");
expect(npmInstallBody).toContain("$npmOutput = Invoke-NpmCommand -Arguments");
+46 -20
View File
@@ -298,7 +298,7 @@ NODE
apk() {
printf 'apk:%s\\n' "$*"
if [[ "$*" == *"nodejs-current"* ]]; then
NODE_FAKE_VERSION=v22.22.2
NODE_FAKE_VERSION=v22.22.3
fi
}
node() {
@@ -320,7 +320,7 @@ NODE
expect(result.stdout).toContain("finish-linux-node");
});
it("fails with Alpine version guidance when apk cannot provide the runtime floor", () => {
it("fails with Alpine guidance when apk cannot provide a safe SQLite runtime", () => {
const result = runInstallShell(`
set -euo pipefail
source "${SCRIPT_PATH}"
@@ -359,9 +359,11 @@ NODE
"step:Installing nodejs-current|apk add --no-cache nodejs-current npm",
);
expect(result.stdout).toContain(
"error:Alpine apk repositories did not provide Node.js 22.19+, 23.11+, or 24+",
"error:Alpine apk repositories did not provide Node.js with WAL-reset-safe SQLite",
);
expect(result.stdout).toContain(
"Use an official node:24-alpine container or a glibc-based host",
);
expect(result.stdout).toContain("Use Alpine 3.21+ or install Node.js 24 manually");
});
it("stops when NodeSource repository setup fails", () => {
@@ -1260,7 +1262,7 @@ NODE
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-nvm-"));
const home = join(tmp, "home");
const systemBin = join(tmp, "system-bin");
const nvmBin = join(home, ".nvm/versions/node/v22.22.1/bin");
const nvmBin = join(home, ".nvm/versions/node/v22.22.3/bin");
mkdirSync(systemBin, { recursive: true });
mkdirSync(nvmBin, { recursive: true });
mkdirSync(join(home, ".nvm"), { recursive: true });
@@ -1268,7 +1270,7 @@ NODE
const systemNode = join(systemBin, "node");
const nvmNode = join(nvmBin, "node");
writeFileSync(systemNode, "#!/bin/sh\necho v8.11.3\n");
writeFileSync(nvmNode, "#!/bin/sh\necho v22.22.1\n");
writeFileSync(nvmNode, "#!/bin/sh\necho v22.22.3\n");
chmodSync(systemNode, 0o755);
chmodSync(nvmNode, 0o755);
writeFileSync(
@@ -1278,7 +1280,7 @@ NODE
"export NVM_DIR",
"nvm() {",
' if [ "$1" = "use" ]; then',
' export PATH="$NVM_DIR/versions/node/v22.22.1/bin:$PATH"',
' export PATH="$NVM_DIR/versions/node/v22.22.3/bin:$PATH"',
" return 0",
" fi",
" return 0",
@@ -1315,7 +1317,7 @@ NODE
const output = result?.stdout ?? "";
expect(output).toContain("status=0");
expect(output).toContain(`path=${nvmNode}`);
expect(output).toContain("version=v22.22.1");
expect(output).toContain("version=v22.22.3");
});
it("installs Homebrew lazily before macOS Git installs", () => {
@@ -1344,7 +1346,7 @@ NODE
const staleNode = join(staleBin, "node");
const supportedNode = join(supportedBin, "node");
writeFileSync(staleNode, "#!/bin/sh\necho v20.20.0\n");
writeFileSync(supportedNode, "#!/bin/sh\necho v22.22.0\n");
writeFileSync(supportedNode, "#!/bin/sh\necho v22.22.3\n");
chmodSync(staleNode, 0o755);
chmodSync(supportedNode, 0o755);
@@ -1384,14 +1386,14 @@ NODE
expect(output).toContain("promote=0");
expect(output).toContain("active=0");
expect(output).toContain(`path=${supportedNode}`);
expect(output).toContain("version=v22.22.0");
expect(output).toContain("version=v22.22.3");
});
it("uses the package engine range when accepting existing Node runtimes", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
engines?: { node?: string };
};
expect(pkg.engines?.node).toBe(">=22.19.0 <23 || >=23.11.0");
expect(pkg.engines?.node).toBe(">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0");
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-floor-"));
const bin = join(tmp, "bin");
@@ -1416,7 +1418,7 @@ NODE
"unset -f node 2>/dev/null || true",
"unalias node 2>/dev/null || true",
'node() { printf "%s\\n" "${FAKE_NODE_VERSION:-v0.0.0}"; }',
"for version in 22.18.9 22.19.0 23.7.0 23.10.9 23.11.0 24.0.0; do",
"for version in 22.22.2 22.22.3 23.11.0 24.14.1 24.15.0 25.8.1 25.9.0 26.0.0; do",
' FAKE_NODE_VERSION="v${version}"',
" export FAKE_NODE_VERSION",
" node_is_supported",
@@ -1434,12 +1436,36 @@ NODE
}
expect(result?.status).toBe(0);
expect(result?.stdout).toContain("22.18.9=1");
expect(result?.stdout).toContain("22.19.0=0");
expect(result?.stdout).toContain("23.7.0=1");
expect(result?.stdout).toContain("23.10.9=1");
expect(result?.stdout).toContain("23.11.0=0");
expect(result?.stdout).toContain("24.0.0=0");
expect(result?.stdout).toContain("22.22.2=1");
expect(result?.stdout).toContain("22.22.3=0");
expect(result?.stdout).toContain("23.11.0=1");
expect(result?.stdout).toContain("24.14.1=1");
expect(result?.stdout).toContain("24.15.0=0");
expect(result?.stdout).toContain("25.8.1=1");
expect(result?.stdout).toContain("25.9.0=0");
expect(result?.stdout).toContain("26.0.0=0");
});
it("rejects a supported Node version when its linked SQLite is unsafe", () => {
const result = runInstallShell(
[
`cd ${JSON.stringify(process.cwd())}`,
`source ${JSON.stringify(SCRIPT_PATH)}`,
"set +e",
"node() {",
' if [[ "${1:-}" == "-v" ]]; then printf "v24.17.0\\n"; return 0; fi',
' if [[ "${1:-}" == "-e" ]]; then return 1; fi',
" return 1",
"}",
"node_is_supported",
'printf "status=%s\\n" "$?"',
"exit 0",
].join("\n"),
{ TERM: "dumb" },
);
expect(result.status).toBe(0);
expect(result.stdout).toContain("status=1");
});
it("persists a supported Linux Node path before noninteractive shell guards", () => {
@@ -1477,8 +1503,8 @@ NODE
installedNode,
[
"#!/usr/bin/env bash",
'if [[ "${1:-}" == "-p" ]]; then echo "24 13"; exit 0; fi',
'if [[ "${1:-}" == "-v" ]]; then echo "v24.13.0"; exit 0; fi',
'if [[ "${1:-}" == "-p" ]]; then echo "24 15"; exit 0; fi',
'if [[ "${1:-}" == "-v" ]]; then echo "v24.15.0"; exit 0; fi',
"",
].join("\n"),
);
@@ -260,7 +260,15 @@ exit 1
it("rejects Node 22 wildcard matches below the supported minimum", () => {
expect(runVersionMatch("22.18.0", "22.x").status).toBe(1);
expect(runVersionMatch("22.19.0", "22.x").status).toBe(0);
expect(runVersionMatch("22.22.2", "22.x").status).toBe(1);
expect(runVersionMatch("22.22.3", "22.x").status).toBe(0);
});
it("enforces patched Node 24 and 25 wildcard minimums", () => {
expect(runVersionMatch("24.14.1", "24.x").status).toBe(1);
expect(runVersionMatch("24.15.0", "24.x").status).toBe(0);
expect(runVersionMatch("25.8.1", "25.x").status).toBe(1);
expect(runVersionMatch("25.9.0", "25.x").status).toBe(0);
});
it("fails clearly when no matching node is available", () => {
+28 -7
View File
@@ -59,7 +59,10 @@ function extractNonrootNodePreflight(): string {
return expectDefined(match[1], "non-root smoke Node preflight capture");
}
function runNonrootNodePreflight(version: string, options: { sqlite?: boolean } = {}) {
function runNonrootNodePreflight(
version: string,
options: { sqlite?: boolean; sqliteVersion?: string } = {},
) {
const stderr: string[] = [];
try {
runInNewContext(extractNonrootNodePreflight(), {
@@ -78,7 +81,17 @@ function runNonrootNodePreflight(version: string, options: { sqlite?: boolean }
if (specifier === "node:sqlite" && options.sqlite === false) {
throw new Error("missing node:sqlite");
}
return {};
return {
DatabaseSync: class {
prepare() {
return {
get: () => ({ version: options.sqliteVersion ?? "3.51.3" }),
};
}
close() {}
},
};
},
});
return { status: 0, stderr: stderr.join("") };
@@ -630,22 +643,30 @@ printf 'status=%s\\n' "$status"
});
it("rejects stale non-root smoke Node runtimes below the runtime floor", () => {
const result = runNonrootNodePreflight("22.18.0");
const result = runNonrootNodePreflight("22.22.2");
expect(result.status).toBe(1);
expect(result.stderr).toContain("unsupported node 22.18.0");
expect(result.stderr).toContain("unsupported node 22.22.2");
});
it("rejects non-root smoke Node runtimes without node:sqlite", () => {
const result = runNonrootNodePreflight("22.19.0", { sqlite: false });
const result = runNonrootNodePreflight("22.22.3", { sqlite: false });
expect(result.status).toBe(1);
expect(result.stderr).toContain("unsupported node 22.19.0: missing node:sqlite");
expect(result.stderr).toContain("unsupported node 22.22.3: missing node:sqlite");
});
it("rejects non-root smoke Node runtimes with vulnerable system SQLite", () => {
const result = runNonrootNodePreflight("24.17.0", { sqliteVersion: "3.51.2" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("unsupported node 24.17.0: unsafe SQLite 3.51.2");
});
it("accepts non-root smoke Node runtimes that match the installer runtime floor", () => {
expect(runNonrootNodePreflight("22.19.0").status).toBe(0);
expect(runNonrootNodePreflight("22.22.3").status).toBe(0);
expect(runNonrootNodePreflight("24.16.0").status).toBe(0);
expect(runNonrootNodePreflight("25.9.0").status).toBe(0);
});
it("runs the root Dockerfile build with the CI heap limit", () => {