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) *x)
[[ "${actual%%.*}" == "${requested%%.*}" ]] || return 1 [[ "${actual%%.*}" == "${requested%%.*}" ]] || return 1
if [[ "${requested%%.*}" == "22" ]]; then 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 fi
;; ;;
*.*.*) *.*.*)
+1 -1
View File
@@ -4,7 +4,7 @@
## Tech Stack ## 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) - **Language**: TypeScript (ESM, strict mode)
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync) - **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`) - **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
+2 -2
View File
@@ -869,7 +869,7 @@ jobs:
env: env:
# GitHub-hosted Linux reports a higher RSS baseline than Blacksmith for # GitHub-hosted Linux reports a higher RSS baseline than Blacksmith for
# the same built CLI. Keep the tighter Blacksmith regression ceiling. # 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: | run: |
set +e set +e
pnpm test:startup:memory pnpm test:startup:memory
@@ -1388,7 +1388,7 @@ jobs:
- name: Setup Node environment - name: Setup Node environment
uses: ./.github/actions/setup-node-env uses: ./.github/actions/setup-node-env
with: with:
node-version: "22.19.0" node-version: "22.22.3"
install-bun: "false" install-bun: "false"
- name: Configure Node test resources - name: Configure Node test resources
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Node environment - name: Setup Node environment
uses: ./.github/actions/setup-node-env uses: ./.github/actions/setup-node-env
with: with:
node-version: "22.19.0" node-version: "22.22.3"
install-bun: "false" install-bun: "false"
- name: Configure Node test resources - name: Configure Node test resources
+1 -1
View File
@@ -112,7 +112,7 @@ Skills own workflows; root owns hard policy and routing.
## Commands ## 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. - 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. - 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`. - 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 ## 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 - 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. - 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. - 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) ## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.19+**. Runtime: **Node 24.15+ (recommended), Node 22.22.3+, or Node 25.9+**.
```bash ```bash
npm install -g openclaw@latest 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) ## 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) 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 ### 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-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass 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: Verify your Node.js version:
```bash ```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 ### Docker Security
+1 -1
View File
@@ -598,7 +598,7 @@ openclaw channels status
</Accordion> </Accordion>
<Accordion title="Bun runtime warning"> <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> </Accordion>
</AccordionGroup> </AccordionGroup>
+1 -1
View File
@@ -26,7 +26,7 @@ openclaw daemon uninstall
| Subcommand | Options | | Subcommand | Options |
| ----------- | ------------------------------------------------------------------------------------------------ | | ----------- | ------------------------------------------------------------------------------------------------ |
| `status` | `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` | | `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` | | `uninstall` | `--json` |
| `start` | `--json` | | `start` | `--json` |
| `stop` | `--json`, `--disable` (launchd only: persistently suppress KeepAlive/RunAtLoad until next start) | | `stop` | `--json`, `--disable` (launchd only: persistently suppress KeepAlive/RunAtLoad until next start) |
+1 -1
View File
@@ -487,7 +487,7 @@ openclaw gateway restart
<AccordionGroup> <AccordionGroup>
<Accordion title="Command options"> <Accordion title="Command options">
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `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 restart`: `--safe`, `--skip-deferral`, `--force`, `--wait <duration>`, `--json`
- `gateway uninstall|start`: `--json` - `gateway uninstall|start`: `--json`
- `gateway stop`: `--disable`, `--json` - `gateway stop`: `--disable`, `--json`
+1 -1
View File
@@ -123,7 +123,7 @@ Options:
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256) - `--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) - `--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 - `--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 - `--force`: Reinstall/overwrite if already installed
Manage the service: 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>`. 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`. 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. hardcoding a fallback.
VM baseline: Linux with a desktop-capable Chrome/Chromium, CDP access, VNC/ 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 noVNC, Node 22.22.3+, 24.15+, or 25.9+ and pnpm, an OpenClaw checkout, and
target transport, GitHub, model providers, and the credential broker. outbound access to the target transport, GitHub, model providers, and the
credential broker.
Secret names used across the Mantis workflows: 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.idleTimeoutMinutes`: positive integer minutes stored for later idle-reclamation policy.
- `lifetime.maxLifetimeMinutes`: positive integer minutes stored for later lifecycle 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. 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. - 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. - 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. - 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`). - Gateway port collision diagnostics (default `18789`).
</Accordion> </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). 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>
<Accordion title="17. Gateway runtime best practices"> <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. 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>
<Accordion title="What runtime do I need?"> <Accordion title="What runtime do I need?">
Node **22.19+** is required (Node 24 recommended). `pnpm` is the repo package manager. Node **22.22.3+**, **24.15+**, or **25.9+** is required (Node 24 recommended). `pnpm` is the repo package manager.
Bun is **not recommended** for the Gateway. Bun can install dependencies and run package scripts, but it cannot run the OpenClaw CLI or Gateway because it lacks `node:sqlite`.
</Accordion> </Accordion>
<Accordion title="Does it run on Raspberry Pi?"> <Accordion title="Does it run on Raspberry Pi?">
@@ -677,9 +677,9 @@ and troubleshooting see the main [FAQ](/help/faq).
</Accordion> </Accordion>
<Accordion title="Can I use Bun?"> <Accordion title="Can I use Bun?">
Not recommended - Bun has runtime bugs, especially with WhatsApp and Telegram. Use You can use Bun to install dependencies or run package scripts. The OpenClaw CLI and
**Node** for stable gateways. If you still want to experiment, do it on a Gateway require **Node** because the canonical state store uses `node:sqlite`; Bun does
non-production gateway without WhatsApp/Telegram. not provide that API.
</Accordion> </Accordion>
<Accordion title="Telegram: what goes in allowFrom?"> <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 - **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
- **Open source**: MIT licensed, community-driven - **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 ## 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) 1. Tailscale (mesh VPN for secure remote access)
2. UFW firewall (SSH + Tailscale ports only) 2. UFW firewall (SSH + Tailscale ports only)
3. Docker CE + Compose V2 (default agent sandbox backend) 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 5. OpenClaw, installed host-based, not containerized
6. A systemd service with security hardening 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: 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 - You hit Bun install/patch/lifecycle script issues
title: "Bun (experimental)" title: "Bun"
--- ---
<Warning> <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> </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 ## Install
@@ -32,6 +32,9 @@ Bun is an optional local runtime for running TypeScript directly (`bun run ...`,
bun run build bun run build
bun run vitest run bun run vitest run
``` ```
Commands that launch OpenClaw itself must still run through Node.
</Step> </Step>
</Steps> </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: 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) - `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts)
If you hit a runtime issue that needs these scripts, trust them explicitly: 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 ## 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). - **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. - `pnpm` is only needed if you build from source.
@@ -106,7 +106,7 @@ If you already manage Node yourself:
``` ```
<Note> <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> </Note>
</Tab> </Tab>
@@ -148,7 +148,7 @@ curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -
Automated fleet provisioning. Automated fleet provisioning.
</Card> </Card>
<Card title="Bun" href="/install/bun" icon="zap"> <Card title="Bun" href="/install/bun" icon="zap">
CLI-only usage via the Bun runtime. Optional dependency installer and package-script runner.
</Card> </Card>
</CardGroup> </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-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. | | [`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 ## Quick commands
@@ -73,8 +73,8 @@ Recommended for most interactive installs on macOS/Linux/WSL.
Supports macOS and Linux (including WSL). Supports macOS and Linux (including WSL).
</Step> </Step>
<Step title="Ensure Node.js 24 by default"> <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. 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; the configured Alpine repositories must provide a supported Node version (Alpine 3.21 or newer at the time of writing). 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>
<Step title="Ensure Git"> <Step title="Ensure Git">
Installs Git if missing using the detected package manager, including Homebrew on macOS and apk on Alpine. 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> <Steps>
<Step title="Install local Node runtime"> <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. 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.
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. 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>
<Step title="Ensure Git"> <Step title="Ensure Git">
If Git is missing, attempts install via apt/dnf/yum/apk on Linux or Homebrew on macOS. 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 \| --github` | Shortcut for git method |
| `--git-dir \| --dir <path>` | Git checkout directory (default: `~/openclaw`) | | `--git-dir \| --dir <path>` | Git checkout directory (default: `~/openclaw`) |
| `--version <ver>` | OpenClaw version or dist-tag (default: `latest`) | | `--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 | | `--json` | Emit NDJSON events |
| `--onboard` | Run `openclaw onboard` after install | | `--onboard` | Run `openclaw onboard` after install |
| `--no-onboard` | Skip onboarding (default) | | `--no-onboard` | Skip onboarding (default) |
@@ -299,7 +300,7 @@ by default, plus git-checkout installs under the same prefix flow.
Requires PowerShell 5+. Requires PowerShell 5+.
</Step> </Step>
<Step title="Ensure Node.js 24 by default"> <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>
<Step title="Install OpenClaw"> <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 - `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" - "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 ## 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 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 ## Install Node
+1 -1
View File
@@ -128,7 +128,7 @@ openclaw node start
openclaw node restart 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 ### Pair + name
+3 -3
View File
@@ -6,9 +6,9 @@ read_when:
title: "Platforms" title: "Platforms"
--- ---
OpenClaw core is written in TypeScript. **Node is the recommended runtime**. OpenClaw core is written in TypeScript. **Node is the required runtime** because
Bun is not recommended for the Gateway — known issues with WhatsApp and the canonical state store uses `node:sqlite`. Bun remains available for
Telegram channels; see [Bun (experimental)](/install/bun) for details. dependency installation and package scripts; see [Bun](/install/bun).
Companion apps exist for Windows Hub, macOS (menu bar app), and mobile nodes 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 (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" title: "Linux app"
--- ---
The Gateway is fully supported on Linux. Node is the recommended runtime; Bun The Gateway is fully supported on Linux and requires Node. Bun can still be used
is not recommended (known WhatsApp/Telegram issues). 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. There is no native Linux companion app yet. Contributions are welcome.
## Quick path (VPS) ## 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` 2. `npm i -g openclaw@latest`
3. `openclaw onboard --install-daemon` 3. `openclaw onboard --install-daemon`
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>` 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) - [Getting Started](/start/getting-started)
- [Install & updates](/install/updating) - [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) ## Gateway service (systemd)
+2 -2
View File
@@ -7,7 +7,7 @@ read_when:
title: "Gateway on macOS" 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 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 a child process, and manages a per-user launchd service to keep the Gateway
running (or attaches to an already-running local Gateway). running (or attaches to an already-running local Gateway).
@@ -26,7 +26,7 @@ OpenClaw package.
## Manual recovery ## 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: `openclaw` globally:
```bash ```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 - **Xcode 26.2+** (Swift 6.2 toolchain), on the latest macOS available in
Software Update. Software Update.
- **Node.js 24 & pnpm** for the gateway, CLI, and packaging scripts. Node - **Node.js 24.15+ & pnpm** for the gateway, CLI, and packaging scripts. Node
22.19+ also works. 22.22.3+ also works.
## 1. Install dependencies ## 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). [`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=...`). - 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). - 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. - 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. - `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 ## 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. - TypeScript ESM modules.
- For in-repo bundled plugin work, clone the repository and run `pnpm install`. - For in-repo bundled plugin work, clone the repository and run `pnpm install`.
Source-checkout plugin development is pnpm-only because OpenClaw discovers 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 ## 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. - TypeScript ESM package output.
- `typebox` in `dependencies` (not just `devDependencies` - the generated - `typebox` in `dependencies` (not just `devDependencies` - the generated
plugin imports it at runtime). 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 No follow-up product decisions are blocking this plan. The implementation should
proceed with these assumptions: proceed with these assumptions:
- Use `node:sqlite` directly and require the Node 22+ runtime for this storage - Use `node:sqlite` directly and require a WAL-reset-safe Node runtime
path. (22.22.3+, 24.15+, or 25.9+) for this storage path.
- Keep exactly one normal configuration file. Do not move config, plugin - Keep exactly one normal configuration file. Do not move config, plugin
manifests, or Git workspaces into SQLite in this refactor. manifests, or Git workspaces into SQLite in this refactor.
- Runtime compatibility files are not required. Legacy JSON and JSONL files are - 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 branch already has a real shared SQLite base:
- The runtime floor is now Node 22+: `package.json`, the CLI runtime guard, - The runtime floor now requires a WAL-reset-safe Node build: 22.22.3+,
installer defaults, macOS runtime locator, CI, and public install docs all 24.15+, or 25.9+. `package.json`, the CLI runtime guard, installer defaults,
agree. The old Node 22 compatibility lane is removed. macOS runtime locator, CI, and public install docs all agree.
- `src/state/openclaw-state-db.ts` opens `openclaw.sqlite`, sets WAL, - `src/state/openclaw-state-db.ts` opens `openclaw.sqlite`, sets WAL,
`synchronous=NORMAL`, `busy_timeout=30000`, `foreign_keys=ON`, and applies `synchronous=NORMAL`, `busy_timeout=30000`, `foreign_keys=ON`, and applies
the generated schema module derived from 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. - 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. - 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. - 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 `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 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. - 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 ## 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 - **An API key** from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you
<Tip> <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) ## 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 - `pnpm` required for source checkouts. OpenClaw loads bundled plugins from the
`extensions/*` pnpm workspace packages in dev mode, so root `npm install` does `extensions/*` pnpm workspace packages in dev mode, so root `npm install` does
not prepare the full source tree. 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 - 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. - 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. - 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>
<Step title="Health check"> <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"'> <ParamField path="skills.install.nodeManager" type='"npm" | "pnpm" | "yarn" | "bun"' default='"npm"'>
Node package manager preference for skill installs. This only affects skill Node package manager preference for skill installs. This only affects skill
installs the Gateway runtime should still use Node (Bun is not installs - the OpenClaw CLI and Gateway runtime require Node because the
recommended for WhatsApp/Telegram). `openclaw setup --node-manager` and canonical state store uses `node:sqlite`. `openclaw setup --node-manager` and
`openclaw onboard --node-manager` accept `npm`, `pnpm`, or `bun`; set `openclaw onboard --node-manager` accept `npm`, `pnpm`, or `bun`; set
`"yarn"` directly in config for Yarn-backed skill installs. `"yarn"` directly in config for Yarn-backed skill installs.
</ParamField> </ParamField>
+1 -1
View File
@@ -72,7 +72,7 @@
"openclaw": "openclaw.mjs" "openclaw": "openclaw.mjs"
}, },
"engines": { "engines": {
"node": ">=22.19.0 <23 || >=23.11.0" "node": ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"sqlite-vec": "0.1.9" "sqlite-vec": "0.1.9"
+36 -32
View File
@@ -8,42 +8,53 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const MIN_NODE_MAJOR = 22; const MIN_NODE_22 = { major: 22, minor: 22, patch: 3 };
const MIN_NODE_MINOR = 19; const MIN_NODE_24 = { major: 24, minor: 15, patch: 0 };
const MIN_NODE_23_MINOR = 11; const MIN_NODE_25 = { major: 25, minor: 9, patch: 0 };
const RECOMMENDED_NODE_MAJOR = 24; const RECOMMENDED_NODE_MAJOR = 24;
const SUPPORTED_NODE_RANGE = ">=22.19.0 <23 or >=23.11.0"; const SUPPORTED_NODE_RANGE = ">=22.22.3 <23, >=24.15.0 <25, or >=25.9.0";
const MIN_COMPILE_CACHE_NODE_24_MINOR = 15;
const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED"; const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED";
const parseNodeVersion = (rawVersion) => { const parseNodeVersion = (rawVersion) => {
const [majorRaw = "0", minorRaw = "0"] = rawVersion.split("."); const [majorRaw = "0", minorRaw = "0", patchRaw = "0"] = rawVersion.split(".");
return { return {
major: Number(majorRaw), major: Number(majorRaw),
minor: Number(minorRaw), 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) => { const isSupportedNodeVersion = (version) => {
if (version.major === MIN_NODE_MAJOR) { if (version.major === MIN_NODE_22.major) {
return version.minor >= MIN_NODE_MINOR; return isAtLeastNodeVersion(version, MIN_NODE_22);
} }
if (version.major === 23) { if (version.major === MIN_NODE_24.major) {
return version.minor >= MIN_NODE_23_MINOR; 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 ensureSupportedRuntimeVersion = () => {
const version = parseNodeVersion(rawVersion); if (process.versions.bun) {
return version.major === 24 && version.minor < MIN_COMPILE_CACHE_NODE_24_MINOR; 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`,
const shouldSkipCompileCacheForWindowsNode24 = () => );
process.platform === "win32" && process.exit(1);
isNodeVersionAffectedByCompileCacheDeadlock(process.versions.node); }
const ensureSupportedNodeVersion = () => {
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) { if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
return; return;
} }
@@ -58,7 +69,7 @@ const ensureSupportedNodeVersion = () => {
process.exit(1); process.exit(1);
}; };
ensureSupportedNodeVersion(); ensureSupportedRuntimeVersion();
if (tryOutputLauncherVersion(process.argv)) { if (tryOutputLauncherVersion(process.argv)) {
process.exit(0); process.exit(0);
@@ -213,9 +224,7 @@ const runRespawnedChild = (command, args, env) => {
}; };
const respawnWithoutCompileCacheIfNeeded = () => { const respawnWithoutCompileCacheIfNeeded = () => {
const needsDisabledCompileCacheRespawn = if (!isSourceCheckoutLauncher()) {
isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
if (!needsDisabledCompileCacheRespawn) {
return false; return false;
} }
if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") { if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
@@ -238,11 +247,7 @@ const respawnWithoutCompileCacheIfNeeded = () => {
}; };
const respawnWithPackagedCompileCacheIfNeeded = () => { const respawnWithPackagedCompileCacheIfNeeded = () => {
if ( if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
isSourceCheckoutLauncher() ||
isNodeCompileCacheDisabled() ||
shouldSkipCompileCacheForWindowsNode24()
) {
return false; return false;
} }
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") { if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
@@ -276,8 +281,7 @@ if (
!waitingForCompileCacheRespawn && !waitingForCompileCacheRespawn &&
module.enableCompileCache && module.enableCompileCache &&
!isNodeCompileCacheDisabled() && !isNodeCompileCacheDisabled() &&
!isSourceCheckoutLauncher() && !isSourceCheckoutLauncher()
!shouldSkipCompileCacheForWindowsNode24()
) { ) {
try { try {
module.enableCompileCache(resolvePackagedCompileCacheDirectory()); module.enableCompileCache(resolvePackagedCompileCacheDirectory());
+1 -1
View File
@@ -2098,7 +2098,7 @@
"sqlite-vec": "0.1.9" "sqlite-vec": "0.1.9"
}, },
"engines": { "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" "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, help: platform === "darwin" ? 300 : 100,
// Plugin discovery is heavier than help, but must stay below the doctor/channel // Plugin discovery is heavier than help, but must stay below the doctor/channel
// runtime graph that an empty metadata-only invocation must not import. // runtime graph that an empty metadata-only invocation must not import.
pluginsList: platform === "darwin" ? 500 : 350, pluginsList: platform === "darwin" ? 500 : 400,
statusJson: 400, statusJson: 400,
gatewayStatus: 500, gatewayStatus: 500,
}; };
+31 -4
View File
@@ -17,20 +17,47 @@ fi
echo "==> Pre-flight: ensure supported Node is already present" echo "==> Pre-flight: ensure supported Node is already present"
node -e ' node -e '
const version = process.versions.node.split(".").map(Number); const [major, minor, patch] = process.versions.node.split(".").map(Number);
const ok = const ok =
version.length >= 2 && (major === 22 && (minor > 22 || (minor === 22 && patch >= 3))) ||
(version[0] > 22 || (version[0] === 22 && version[1] >= 19)); (major === 24 && minor >= 15) ||
(major === 25 && minor >= 9) ||
major >= 26;
if (!ok) { if (!ok) {
process.stderr.write(`unsupported node ${process.versions.node}\n`); process.stderr.write(`unsupported node ${process.versions.node}\n`);
process.exit(1); process.exit(1);
} }
let sqliteVersion;
try { 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 { } catch {
process.stderr.write(`unsupported node ${process.versions.node}: missing node:sqlite\n`); process.stderr.write(`unsupported node ${process.versions.node}: missing node:sqlite\n`);
process.exit(1); 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 command -v npm >/dev/null
+78 -16
View File
@@ -64,14 +64,17 @@ resolve_openclaw_effective_home() {
OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)" OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)"
PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}" PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}"
OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}" 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 NODE_VERSION_REQUESTED=0
if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then
NODE_VERSION_REQUESTED=1 NODE_VERSION_REQUESTED=1
fi fi
MIN_NODE_VERSION="22.19.0" MIN_NODE_22_VERSION="22.22.3"
MIN_NODE_23_VERSION="23.11.0" MIN_NODE_24_VERSION="24.15.0"
SUPPORTED_NODE_VERSION_LABEL="Node 22.19+, Node 23.11+, or Node 24+" 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" APK_NODE_BIN_DIR="/usr/bin"
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}" NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}" INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
@@ -92,7 +95,7 @@ Usage: install-cli.sh [options]
--git, --github Shortcut for --install-method git --git, --github Shortcut for --install-method git
--git-dir, --dir <path> Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw) --git-dir, --dir <path> Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw)
--version <ver> OpenClaw version (default: latest) --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 --onboard Run "openclaw onboard" after install
--no-onboard Skip onboarding (default) --no-onboard Skip onboarding (default)
--set-npm-prefix Force npm prefix to ~/.npm-global if current prefix is not writable (Linux) --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)" arch="$(uname -m)"
case "$arch" in case "$arch" in
arm64|aarch64) echo "arm64" ;; arm64|aarch64) echo "arm64" ;;
armv7|armv7l) echo "armv7l" ;;
x86_64|amd64) echo "x64" ;; x86_64|amd64) echo "x64" ;;
*) fail "Unsupported architecture: $arch" ;; *) fail "Unsupported architecture: $arch" ;;
esac 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() { node_dir() {
echo "${PREFIX}/tools/node-v${NODE_VERSION}" echo "${PREFIX}/tools/node-v${NODE_VERSION}"
} }
@@ -444,7 +459,45 @@ linked_node_is_usable() {
return 1 return 1
fi 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() { semver_at_least() {
@@ -491,14 +544,18 @@ node_version_is_supported() {
done done
if ((major == 22)); then if ((major == 22)); then
semver_at_least "$version" "$MIN_NODE_VERSION" semver_at_least "$version" "$MIN_NODE_22_VERSION"
return return
fi fi
if ((major == 23)); then if ((major == 24)); then
semver_at_least "$version" "$MIN_NODE_23_VERSION" semver_at_least "$version" "$MIN_NODE_24_VERSION"
return return
fi fi
((major > 23)) if ((major == 25)); then
semver_at_least "$version" "$MIN_NODE_25_VERSION"
return
fi
((major > 25))
} }
required_node_version() { required_node_version() {
@@ -506,7 +563,7 @@ required_node_version() {
printf '%s\n' "$NODE_VERSION" printf '%s\n' "$NODE_VERSION"
return return
fi fi
printf '%s\n' "$MIN_NODE_VERSION" printf '%s\n' "$MIN_NODE_22_VERSION"
} }
try_link_usable_node_runtime_from_path() { try_link_usable_node_runtime_from_path() {
@@ -536,6 +593,7 @@ try_link_usable_node_runtime_from_path() {
install_alpine_node() { install_alpine_node() {
local installed_version local installed_version
local required_version local required_version
local sqlite_version
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"method\":\"apk\"}" emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"start\",\"method\":\"apk\"}"
if try_link_usable_node_runtime_from_path; then if try_link_usable_node_runtime_from_path; then
@@ -562,7 +620,8 @@ install_alpine_node() {
if ! linked_node_is_usable; then if ! linked_node_is_usable; then
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)" installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
required_version="$(required_node_version)" 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 fi
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)" installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
@@ -808,12 +867,12 @@ install_node() {
local expected_sha local expected_sha
local actual_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 if ! node_version_is_supported "$NODE_VERSION"; then
fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}." fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}."
fi fi
os="$(os_detect)"
arch="$(arch_detect)"
dir="$(node_dir)" dir="$(node_dir)"
if [[ "$os" == "linux" ]] && command -v apk >/dev/null 2>&1 && is_musl_linux; then 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 if ! linked_node_is_usable; then
local installed_version local installed_version
local required_version local required_version
local sqlite_version
installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)" installed_version="$("$(node_bin)" -v 2>/dev/null || echo unknown)"
required_version="$(required_node_version)" 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 fi
emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"version\":\"${NODE_VERSION}\"}" emit_json "{\"event\":\"step\",\"name\":\"node\",\"status\":\"ok\",\"version\":\"${NODE_VERSION}\"}"
} }
@@ -1230,6 +1291,7 @@ main() {
RUN_ONBOARD=0 RUN_ONBOARD=0
fi fi
select_node_version_for_platform "$(os_detect)" "$(arch_detect)"
PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}" PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
export PATH export PATH
+67 -17
View File
@@ -152,30 +152,46 @@ if ([string]::IsNullOrWhiteSpace($GitDir)) {
function Test-NodeVersionSupported { function Test-NodeVersionSupported {
param([string]$Version) 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) { if (-not $versionMatch.Success) {
return $false return $false
} }
$major = [int]$versionMatch.Groups["major"].Value $major = [int]$versionMatch.Groups["major"].Value
$minor = [int]$versionMatch.Groups["minor"].Value $minor = [int]$versionMatch.Groups["minor"].Value
$patch = [int]$versionMatch.Groups["patch"].Value
if ($major -eq 22) { if ($major -eq 22) {
return ($minor -ge 19) return ($minor -gt 22 -or ($minor -eq 22 -and $patch -ge 3))
} }
if ($major -eq 23) { if ($major -eq 24) {
return ($minor -ge 11) 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 { function Check-Node {
try { try {
$nodeVersion = (node -v 2>$null) $nodeVersion = (node -v 2>$null)
if ($nodeVersion) { if ($nodeVersion) {
if (Test-NodeVersionSupported -Version $nodeVersion) { if ((Test-NodeVersionSupported -Version $nodeVersion) -and (Test-NodeSqliteSupported)) {
Write-Host "[OK] Node.js $nodeVersion found" -ForegroundColor Green Write-Host "[OK] Node.js $nodeVersion found" -ForegroundColor Green
return $true 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 { } 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 return $false
} }
} }
@@ -371,10 +387,10 @@ function Install-Node {
# Try winget first (Windows 11 / Windows 10 with App Installer) # Try winget first (Windows 11 / Windows 10 with App Installer)
if (Get-Command winget -ErrorAction SilentlyContinue) { if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Host " Using winget..." -ForegroundColor Gray 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 # Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") Refresh-ProcessPath
if (Check-Node) { if (Check-Node) {
Write-Host "[OK] Node.js installed via winget" -ForegroundColor Green Write-Host "[OK] Node.js installed via winget" -ForegroundColor Green
return $true return $true
@@ -387,20 +403,33 @@ function Install-Node {
# Try Chocolatey # Try Chocolatey
if (Get-Command choco -ErrorAction SilentlyContinue) { if (Get-Command choco -ErrorAction SilentlyContinue) {
Write-Host " Using Chocolatey..." -ForegroundColor Gray Write-Host " Using Chocolatey..." -ForegroundColor Gray
choco install nodejs-lts -y choco upgrade nodejs-lts -y --install-if-not-installed | Out-Host
# Refresh PATH # Refresh PATH
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") Refresh-ProcessPath
Write-Host "[OK] Node.js installed via Chocolatey" -ForegroundColor Green if (Check-Node) {
return $true 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 # Try Scoop
if (Get-Command scoop -ErrorAction SilentlyContinue) { if (Get-Command scoop -ErrorAction SilentlyContinue) {
Write-Host " Using Scoop..." -ForegroundColor Gray Write-Host " Using Scoop..." -ForegroundColor Gray
scoop install nodejs-lts scoop update | Out-Host
Write-Host "[OK] Node.js installed via Scoop" -ForegroundColor Green scoop install nodejs-lts | Out-Host
return $true 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 { try {
@@ -416,7 +445,7 @@ function Install-Node {
Write-Host "" Write-Host ""
Write-Host "Error: Could not install Node.js automatically." -ForegroundColor Red Write-Host "Error: Could not install Node.js automatically." -ForegroundColor Red
Write-Host "" 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 " https://nodejs.org/en/download/" -ForegroundColor Cyan
Write-Host "" Write-Host ""
Write-Host "Or install winget (App Installer) from the Microsoft Store." -ForegroundColor Gray 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" $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 { function Add-ToUserPath {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
+92 -29
View File
@@ -18,9 +18,13 @@ NC='\033[0m' # No Color
DEFAULT_TAGLINE="All your chats, one OpenClaw." DEFAULT_TAGLINE="All your chats, one OpenClaw."
NODE_DEFAULT_MAJOR=24 NODE_DEFAULT_MAJOR=24
NODE_MIN_MAJOR=22 NODE_MIN_MAJOR=22
NODE_MIN_MINOR=19 NODE_22_MIN_MINOR=22
NODE_23_MIN_MINOR=11 NODE_22_MIN_PATCH=3
NODE_SUPPORTED_VERSION_LABEL="22.19+, 23.11+, or 24+" 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:-}" ORIGINAL_PATH="${PATH:-}"
@@ -1535,13 +1539,17 @@ parse_node_version_components_for_binary() {
if ! command -v "$node_bin" &> /dev/null && [[ ! -x "$node_bin" ]]; then if ! command -v "$node_bin" &> /dev/null && [[ ! -x "$node_bin" ]]; then
return 1 return 1
fi fi
local version major minor local version major minor patch
version="$("$node_bin" -v 2>/dev/null || true)" version="$("$node_bin" -v 2>/dev/null || true)"
major="${version#v}" major="${version#v}"
major="${major%%.*}" major="${major%%.*}"
minor="${version#v}" minor="${version#v}"
minor="${minor#*.}" minor="${minor#*.}"
minor="${minor%%.*}" minor="${minor%%.*}"
patch="${version#v}"
patch="${patch#*.}"
patch="${patch#*.}"
patch="${patch%%.*}"
if [[ ! "$major" =~ ^[0-9]+$ ]]; then if [[ ! "$major" =~ ^[0-9]+$ ]]; then
return 1 return 1
@@ -1549,7 +1557,10 @@ parse_node_version_components_for_binary() {
if [[ ! "$minor" =~ ^[0-9]+$ ]]; then if [[ ! "$minor" =~ ^[0-9]+$ ]]; then
return 1 return 1
fi fi
echo "${major} ${minor}" if [[ ! "$patch" =~ ^[0-9]+$ ]]; then
return 1
fi
echo "${major} ${minor} ${patch}"
return 0 return 0
} }
@@ -1561,9 +1572,9 @@ parse_node_version_components() {
} }
node_major_version() { node_major_version() {
local version_components major minor local version_components major minor patch
version_components="$(parse_node_version_components || true)" 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 if [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]]; then
echo "$major" echo "$major"
return 0 return 0
@@ -1574,37 +1585,88 @@ node_major_version() {
node_version_components_are_supported() { node_version_components_are_supported() {
local major="$1" local major="$1"
local minor="$2" local minor="$2"
if [[ "$major" -eq "$NODE_MIN_MAJOR" && "$minor" -ge "$NODE_MIN_MINOR" ]]; then local patch="$3"
return 0
fi case "$major" in
if [[ "$major" -eq 23 && "$minor" -ge "$NODE_23_MIN_MINOR" ]]; then "$NODE_MIN_MAJOR")
return 0 ((minor > NODE_22_MIN_MINOR)) ||
fi ((minor == NODE_22_MIN_MINOR && patch >= NODE_22_MIN_PATCH))
if [[ "$major" -gt 23 ]]; then ;;
return 0 24)
fi ((minor > NODE_24_MIN_MINOR)) ||
return 1 ((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() { node_is_supported() {
local version_components major minor local version_components major minor patch
version_components="$(parse_node_version_components || true)" 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 if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ || ! "$patch" =~ ^[0-9]+$ ]]; then
return 1 return 1
fi 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() { node_binary_is_supported() {
local node_bin="$1" 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)" version_components="$(parse_node_version_components_for_binary "$node_bin" || true)"
read -r major minor <<< "$version_components" read -r major minor patch <<< "$version_components"
if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ ]]; then if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ || ! "$patch" =~ ^[0-9]+$ ]]; then
return 1 return 1
fi 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() { prepend_path_dir() {
@@ -1858,7 +1920,7 @@ install_node_with_apk() {
local apk_node_version local apk_node_version
apk_node_version="$(node -v 2>/dev/null || echo "missing")" 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" ui_info "Trying Alpine nodejs-current package"
if is_root; then if is_root; then
run_required_step "Installing nodejs-current" apk add --no-cache nodejs-current npm run_required_step "Installing nodejs-current" apk add --no-cache nodejs-current npm
@@ -1872,11 +1934,12 @@ install_node_with_apk() {
return 0 return 0
fi 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_path="$(command -v node 2>/dev/null || echo "not found")"
active_version="$(node -v 2>/dev/null || echo "missing")" 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})" sqlite_version="$(node_binary_sqlite_version node)"
echo "Use Alpine 3.21+ or install Node.js ${NODE_DEFAULT_MAJOR} manually, then rerun the installer." 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 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; const runtimeRaw = opts.runtime ? opts.runtime : DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) { if (!isGatewayDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")'); fail('Invalid --runtime (use "node"; Bun lacks the required node:sqlite API)');
return; return;
} }
let wrapperPath: string | undefined; let wrapperPath: string | undefined;
@@ -84,7 +84,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
.command("install") .command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)") .description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port") .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("--token <token>", "Gateway token (token auth)")
.option("--wrapper <path>", "Executable wrapper for generated service ProgramArguments") .option("--wrapper <path>", "Executable wrapper for generated service ProgramArguments")
.option("--force", "Reinstall/overwrite if already installed", false) .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; const runtimeRaw = opts.runtime ? opts.runtime : DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) { if (!isGatewayDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")'); fail('Invalid --runtime (use "node"; Bun lacks the required node:sqlite API)');
return; return;
} }
+1 -1
View File
@@ -130,7 +130,7 @@ export function registerNodeCli(program: Command) {
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)") .option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override the generated node instance id") .option("--node-id <id>", "Override the generated node instance id")
.option("--display-name <name>", "Override node display name") .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("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false) .option("--json", "Output JSON", false)
.action(async (opts) => { .action(async (opts) => {
+1 -1
View File
@@ -232,7 +232,7 @@ export function registerOnboardCommand(program: Command): void {
.option("--install-daemon", "Install gateway service") .option("--install-daemon", "Install gateway service")
.option("--no-install-daemon", "Skip gateway service install") .option("--no-install-daemon", "Skip gateway service install")
.option("--skip-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-channels", "Skip channel setup")
.option("--skip-skills", "Skip skills setup") .option("--skip-skills", "Skip skills setup")
.option("--skip-bootstrap", "Skip creating default agent workspace files") .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("--install-daemon", "Install gateway service")
.option("--no-install-daemon", "Skip gateway service install") .option("--no-install-daemon", "Skip gateway service install")
.option("--skip-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-channels", "Skip channel setup")
.option("--skip-skills", "Skip skills setup") .option("--skip-skills", "Skip skills setup")
.option("--skip-bootstrap", "Skip creating default agent workspace files") .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}.`, `The requested package requires ${status.nodeEngine}.`,
runtime.nodeRunner runtime.nodeRunner
? "Upgrade the Node runtime that owns the managed Gateway service, then rerun `openclaw update`." ? "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.", "Bare `npm i -g openclaw` can silently install an older compatible release.",
"After upgrading Node, use `npm i -g openclaw@latest`.", "After upgrading Node, use `npm i -g openclaw@latest`.",
].join("\n"); ].join("\n");
@@ -18,19 +18,6 @@ afterEach(() => {
}); });
describe("emitNodeRuntimeWarning", () => { 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 () => { it("emits warning when system node check returns one", async () => {
const warn = vi.fn(); const warn = vi.fn();
mocks.resolveSystemNodeInfo.mockResolvedValue({ path: "/usr/bin/node", version: "18.0.0" }); 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. // Runtime warning helpers for daemon install plans that depend on Node.
import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js"; import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type DaemonInstallWarnFn = (message: string, title?: string) => void; export type DaemonInstallWarnFn = (message: string, title?: string) => void;
/** Warn when daemon install will use a system Node path that may be unsuitable. */ /** Warn when daemon install will use a system Node path that may be unsuitable. */
export async function emitNodeRuntimeWarning(params: { export async function emitNodeRuntimeWarning(params: {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
runtime: string; runtime: GatewayDaemonRuntime;
nodeProgram?: string; nodeProgram?: string;
warn?: DaemonInstallWarnFn; warn?: DaemonInstallWarnFn;
title: string; title: string;
}): Promise<void> { }): Promise<void> {
if (params.runtime !== "node") {
return;
}
const systemNode = await resolveSystemNodeInfo({ env: params.env }); const systemNode = await resolveSystemNodeInfo({ env: params.env });
const warning = renderSystemNodeWarning(systemNode, params.nodeProgram); const warning = renderSystemNodeWarning(systemNode, params.nodeProgram);
if (warning) { 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. // 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"; export const DEFAULT_GATEWAY_DAEMON_RUNTIME: GatewayDaemonRuntime = "node";
@@ -10,12 +10,12 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
}> = [ }> = [
{ {
value: "node", value: "node",
label: "Node (recommended)", label: "Node",
hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.", hint: "Required for OpenClaw's SQLite-backed runtime state.",
}, },
]; ];
/** Narrow arbitrary input to a supported Gateway daemon runtime id. */ /** Narrow arbitrary input to a supported Gateway daemon runtime id. */
export function isGatewayDaemonRuntime(value: string | undefined): value is GatewayDaemonRuntime { 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 () => { 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({ mocks.readCommand.mockResolvedValue({
programArguments: [nvmNode, "/usr/local/bin/openclaw", "gateway", "--port", "18789"], programArguments: [nvmNode, "/usr/local/bin/openclaw", "gateway", "--port", "18789"],
environment: {}, environment: {},
}); });
mocks.buildGatewayInstallPlan.mockImplementation(async ({ warn }) => { mocks.buildGatewayInstallPlan.mockImplementation(async ({ warn }) => {
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", "Gateway runtime",
); );
return { return {
@@ -492,7 +492,7 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect(runtimeMessages).not.toContain("duplicate doctor runtime warning"); 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")).not.toContain("not found");
expect(runtimeMessages.map((message) => String(message)).join("\n")).toContain( 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) { if (first) {
const base = normalizeLowercaseStringOrEmpty(path.basename(first)); const base = normalizeLowercaseStringOrEmpty(path.basename(first));
if (base === "bun" || base === "bun.exe") { 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") { if (base === "node" || base === "node.exe") {
return "node"; return "node";
@@ -556,7 +556,7 @@ export async function maybeRepairGatewayServiceConfig(
note(warning, "Gateway runtime"); note(warning, "Gateway runtime");
} else { } else {
note( 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", "Gateway runtime",
); );
} }
@@ -48,7 +48,7 @@ export async function installGatewayDaemonNonInteractive(params: {
} }
if (!isGatewayDaemonRuntime(daemonRuntimeRaw)) { 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); runtime.exit(1);
return { installed: false }; return { installed: false };
} }
+6 -52
View File
@@ -1,12 +1,6 @@
// Daemon program argument tests cover CLI argument construction for services. // Daemon program argument tests cover CLI argument construction for services.
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; 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(() => ({ const fsMocks = vi.hoisted(() => ({
access: vi.fn(), 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"; import { resolveGatewayProgramArguments, resolveNodeProgramArguments } from "./program-args.js";
const originalArgv = [...process.argv]; const originalArgv = [...process.argv];
@@ -46,7 +32,6 @@ afterEach(() => {
process.argv = [...originalArgv]; process.argv = [...originalArgv];
vi.resetAllMocks(); vi.resetAllMocks();
vi.unstubAllEnvs(); vi.unstubAllEnvs();
resetWindowsInstallRootsForTests();
}); });
describe("resolveGatewayProgramArguments", () => { 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 repoIndexPath = path.resolve("/repo/src/index.ts");
const repoEntryPath = path.resolve("/repo/src/entry.ts"); const repoEntryPath = path.resolve("/repo/src/entry.ts");
process.argv = ["/usr/local/bin/node", repoIndexPath]; process.argv = ["/usr/local/bin/node", repoIndexPath];
fsMocks.realpath.mockResolvedValue(repoIndexPath); fsMocks.realpath.mockResolvedValue(repoIndexPath);
fsMocks.access.mockResolvedValue(undefined); fsMocks.access.mockResolvedValue(undefined);
childProcessMocks.execFileSync.mockReturnValue("/usr/local/bin/bun\n");
const result = await resolveGatewayProgramArguments({ const result = await resolveGatewayProgramArguments({
dev: true, dev: true,
port: 18789, port: 18789,
runtime: "bun", runtime: "node",
nodePath: "/usr/local/bin/node",
}); });
expect(result.programArguments).toEqual([ expect(result.programArguments).toEqual([
"/usr/local/bin/bun", "/usr/local/bin/node",
"--import",
"tsx",
repoEntryPath, repoEntryPath,
"gateway", "gateway",
"--port", "--port",
@@ -183,39 +170,6 @@ describe("resolveGatewayProgramArguments", () => {
expect(result.workingDirectory).toBe(path.resolve("/repo")); 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 () => { it("uses an executable wrapper when provided", async () => {
const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler"); const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler");
fsMocks.stat.mockResolvedValue({ isFile: () => true } as never); fsMocks.stat.mockResolvedValue({ isFile: () => true } as never);
+12 -68
View File
@@ -9,14 +9,14 @@ import {
findFirstAccessibleGatewayEntrypoint, findFirstAccessibleGatewayEntrypoint,
isGatewayDistEntrypointPath, isGatewayDistEntrypointPath,
} from "./gateway-entrypoint.js"; } from "./gateway-entrypoint.js";
import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js"; import { isNodeRuntime } from "./runtime-binary.js";
type GatewayProgramArgs = { type GatewayProgramArgs = {
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
}; };
type GatewayRuntimePreference = "auto" | "node" | "bun"; type GatewayRuntimePreference = "auto" | "node";
export const OPENCLAW_WRAPPER_ENV_KEY = "OPENCLAW_WRAPPER"; export const OPENCLAW_WRAPPER_ENV_KEY = "OPENCLAW_WRAPPER";
@@ -155,11 +155,6 @@ function resolveRepoRootForDev(): string {
return parts.slice(0, srcIndex).join(path.sep); 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> { async function resolveNodePath(): Promise<string> {
const nodePath = await resolveBinaryPath("node"); const nodePath = await resolveBinaryPath("node");
return nodePath; return nodePath;
@@ -176,11 +171,8 @@ async function resolveBinaryPath(binary: string): Promise<string> {
await fs.access(resolved); await fs.access(resolved);
return resolved; return resolved;
} catch { } catch {
if (binary === "bun") {
throw new Error("Bun not found in PATH. Install bun: https://bun.sh");
}
throw new Error( 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 execPath = process.execPath;
const runtime = params.runtime ?? "auto"; const nodePath =
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
if (runtime === "node") { if (params.dev) {
const nodePath = const repoRoot = resolveRepoRootForDev();
params.nodePath ?? (isNodeRuntime(execPath) ? execPath : await resolveNodePath()); const devCliPath = path.join(repoRoot, "src", "entry.ts");
const cliEntrypointPath = await resolveCliEntrypointPathForService(); await fs.access(devCliPath);
return { return {
programArguments: [nodePath, cliEntrypointPath, ...params.args], programArguments: [nodePath, "--import", "tsx", devCliPath, ...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],
workingDirectory: repoRoot, workingDirectory: repoRoot,
}; };
} }
// Otherwise resolve bun from PATH. const cliEntrypointPath = await resolveCliEntrypointPathForService();
const bunPath = await resolveBunPath();
return { return {
programArguments: [bunPath, devCliPath, ...params.args], programArguments: [nodePath, cliEntrypointPath, ...params.args],
workingDirectory: repoRoot,
}; };
} }
+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", () => { describe("resolvePreferredNodePath", () => {
const darwinNode = "/opt/homebrew/bin/node"; 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 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 () => { it("prefers supported system node over version-manager execPath", async () => {
mockNodePathPresent(darwinNode); mockNodePathPresent(darwinNode);
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" }) .mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" }); .mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -76,8 +83,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" }) .mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" }); .mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -97,8 +104,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" }) .mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" }); .mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -118,8 +125,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" }) .mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" }); .mockResolvedValueOnce(nodeRuntime("24.15.0"));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -139,8 +146,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" }) .mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce({ stdout: "22.19.0\n", stderr: "" }); .mockResolvedValueOnce(nodeRuntime("22.22.3"));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -159,8 +166,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" }) .mockResolvedValueOnce(nodeRuntime("24.15.0"))
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" }); .mockResolvedValueOnce(nodeRuntime("18.0.0", null));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -179,8 +186,8 @@ describe("resolvePreferredNodePath", () => {
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" }) // execPath too old .mockResolvedValueOnce(nodeRuntime("18.0.0", null)) // execPath too old
.mockResolvedValueOnce({ stdout: "22.19.0\n", stderr: "" }); // system node ok .mockResolvedValueOnce(nodeRuntime("22.22.3")); // system node ok
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -197,7 +204,7 @@ describe("resolvePreferredNodePath", () => {
it("ignores execPath when it is not node", async () => { it("ignores execPath when it is not node", async () => {
mockNodePathPresent(darwinNode); 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({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -209,16 +216,18 @@ describe("resolvePreferredNodePath", () => {
expect(result).toBe(darwinNode); expect(result).toBe(darwinNode);
expect(execFile).toHaveBeenCalledTimes(1); expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledWith(darwinNode, ["-p", "process.versions.node"], { expect(execFile).toHaveBeenCalledWith(
encoding: "utf8", darwinNode,
}); ["-e", expect.stringContaining("SELECT sqlite_version() AS version")],
{ encoding: "utf8" },
);
}); });
it("uses system node when it meets the minimum version", async () => { it("uses system node when it meets the minimum version", async () => {
mockNodePathPresent(darwinNode); mockNodePathPresent(darwinNode);
// Node 22.19.0+ is the minimum required version // Node 22.22.3+ is the minimum required version
const execFile = vi.fn().mockResolvedValue({ stdout: "22.19.0\n", stderr: "" }); const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.3"));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -235,8 +244,8 @@ describe("resolvePreferredNodePath", () => {
it("skips system node when it is too old", async () => { it("skips system node when it is too old", async () => {
mockNodePathPresent(darwinNode); mockNodePathPresent(darwinNode);
// Node 22.18.x is below minimum 22.19.0 // Node 22.22.2 is below minimum 22.22.3
const execFile = vi.fn().mockResolvedValue({ stdout: "22.18.0\n", stderr: "" }); const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.2", null));
const result = await resolvePreferredNodePath({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -250,6 +259,44 @@ describe("resolvePreferredNodePath", () => {
expect(execFile).toHaveBeenCalledTimes(1); 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 () => { it("returns undefined when no system node is found", async () => {
fsMocks.access.mockRejectedValue(new Error("missing")); fsMocks.access.mockRejectedValue(new Error("missing"));
@@ -271,41 +318,41 @@ describe("resolveStableNodePath", () => {
it("resolves Homebrew Cellar path to opt symlink", async () => { it("resolves Homebrew Cellar path to opt symlink", async () => {
mockNodePathPresent("/opt/homebrew/opt/node/bin/node"); 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"); expect(result).toBe("/opt/homebrew/opt/node/bin/node");
}); });
it("falls back to bin symlink for default node formula", async () => { it("falls back to bin symlink for default node formula", async () => {
mockNodePathPresent("/opt/homebrew/bin/node"); 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"); expect(result).toBe("/opt/homebrew/bin/node");
}); });
it("resolves Intel Mac Cellar path to opt symlink", async () => { it("resolves Intel Mac Cellar path to opt symlink", async () => {
mockNodePathPresent("/usr/local/opt/node/bin/node"); 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"); expect(result).toBe("/usr/local/opt/node/bin/node");
}); });
it("resolves versioned node@22 formula to opt symlink", async () => { it("resolves versioned node@22 formula to opt symlink", async () => {
mockNodePathPresent("/opt/homebrew/opt/node@22/bin/node"); 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"); expect(result).toBe("/opt/homebrew/opt/node@22/bin/node");
}); });
it("returns original path when no stable symlink exists", async () => { it("returns original path when no stable symlink exists", async () => {
fsMocks.access.mockRejectedValue(new Error("missing")); 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); const result = await resolveStableNodePath(cellarPath);
expect(result).toBe(cellarPath); expect(result).toBe(cellarPath);
}); });
it("returns non-Cellar paths unchanged", async () => { 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); const result = await resolveStableNodePath(fnmPath);
expect(result).toBe(fnmPath); expect(result).toBe(fnmPath);
}); });
@@ -318,11 +365,11 @@ describe("resolveStableNodePath", () => {
describe("resolvePreferredNodePath — Homebrew Cellar", () => { describe("resolvePreferredNodePath — Homebrew Cellar", () => {
it("resolves Cellar execPath to stable Homebrew symlink", async () => { 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"; const stableNode = "/opt/homebrew/opt/node/bin/node";
mockNodePathPresent(stableNode); 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({ const result = await resolvePreferredNodePath({
env: {}, env: {},
@@ -342,8 +389,8 @@ describe("resolveSystemNodeInfo", () => {
it("returns supported info when version is new enough", async () => { it("returns supported info when version is new enough", async () => {
mockNodePathPresent(darwinNode); mockNodePathPresent(darwinNode);
// Node 22.19.0+ is the minimum required version // Node 22.22.3+ is the minimum required version
const execFile = vi.fn().mockResolvedValue({ stdout: "22.19.0\n", stderr: "" }); const execFile = vi.fn().mockResolvedValue(nodeRuntime("22.22.3"));
const result = await resolveSystemNodeInfo({ const result = await resolveSystemNodeInfo({
env: {}, env: {},
@@ -353,7 +400,8 @@ describe("resolveSystemNodeInfo", () => {
expect(result).toEqual({ expect(result).toEqual({
path: darwinNode, path: darwinNode,
version: "22.19.0", sqliteVersion: "3.51.3",
version: "22.22.3",
supported: true, supported: true,
}); });
}); });
@@ -371,8 +419,8 @@ describe("resolveSystemNodeInfo", () => {
const execFile = vi const execFile = vi
.fn() .fn()
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" }) .mockResolvedValueOnce(nodeRuntime("18.0.0", null))
.mockResolvedValueOnce({ stdout: "22.19.0\n", stderr: "" }); .mockResolvedValueOnce(nodeRuntime("22.22.3"));
const result = await resolveSystemNodeInfo({ const result = await resolveSystemNodeInfo({
env: {}, env: {},
@@ -382,7 +430,8 @@ describe("resolveSystemNodeInfo", () => {
expect(result).toEqual({ expect(result).toEqual({
path: homebrewOptNode, path: homebrewOptNode,
version: "22.19.0", sqliteVersion: "3.51.3",
version: "22.22.3",
supported: true, supported: true,
}); });
}); });
@@ -395,7 +444,7 @@ describe("resolveSystemNodeInfo", () => {
[homebrewOptNode]: homebrewOptNode, [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({ const result = await resolveSystemNodeInfo({
env: {}, env: {},
@@ -405,13 +454,16 @@ describe("resolveSystemNodeInfo", () => {
expect(result).toEqual({ expect(result).toEqual({
path: homebrewOptNode, path: homebrewOptNode,
version: "24.14.1", sqliteVersion: "3.51.3",
version: "24.15.0",
supported: true, supported: true,
}); });
expect(execFile).toHaveBeenCalledTimes(1); expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledWith(homebrewOptNode, ["-p", "process.versions.node"], { expect(execFile).toHaveBeenCalledWith(
encoding: "utf8", 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 () => { it("returns null when every system-node candidate resolves into a version manager", async () => {
@@ -436,6 +488,7 @@ describe("resolveSystemNodeInfo", () => {
const warning = renderSystemNodeWarning( const warning = renderSystemNodeWarning(
{ {
path: darwinNode, path: darwinNode,
sqliteVersion: null,
version: "18.19.0", version: "18.19.0",
supported: false, supported: false,
}, },
@@ -446,11 +499,23 @@ describe("resolveSystemNodeInfo", () => {
expect(warning).toContain(darwinNode); 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 () => { it("uses validated custom Program Files roots on Windows", async () => {
const customNode = "D:\\Programs\\nodejs\\node.exe"; const customNode = "D:\\Programs\\nodejs\\node.exe";
mockNodePathPresent(customNode); 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({ const result = await resolveSystemNodeInfo({
env: { env: {
ProgramFiles: "D:\\Programs", ProgramFiles: "D:\\Programs",
@@ -468,7 +533,7 @@ describe("resolveSystemNodeInfo", () => {
const x86Node = "E:\\Programs (x86)\\nodejs\\node.exe"; const x86Node = "E:\\Programs (x86)\\nodejs\\node.exe";
mockNodePathPresent(preferredNode, x86Node); 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({ const result = await resolveSystemNodeInfo({
env: { env: {
ProgramFiles: "E:\\Programs (x86)", ProgramFiles: "E:\\Programs (x86)",
+51 -12
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { isSupportedNodeVersion } from "../infra/runtime-guard.js"; import { isSupportedNodeVersion } from "../infra/runtime-guard.js";
import { isSqliteWalResetSafeVersion } from "../infra/sqlite-runtime-version.js";
import { resolveStableNodePath } from "../infra/stable-node-path.js"; import { resolveStableNodePath } from "../infra/stable-node-path.js";
import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js"; import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js";
@@ -80,23 +81,56 @@ type ExecFileAsync = (
const execFileAsync = promisify(execFile) as unknown as 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, nodePath: string,
execFileImpl: ExecFileAsync, execFileImpl: ExecFileAsync,
): Promise<string | null> { ): Promise<NodeRuntimeInfo> {
try { try {
const { stdout } = await execFileImpl(nodePath, ["-p", "process.versions.node"], { const { stdout } = await execFileImpl(nodePath, ["-e", NODE_RUNTIME_PROBE], {
encoding: "utf8", encoding: "utf8",
}); });
const value = stdout.trim(); const parsed = JSON.parse(stdout) as {
return value ? value : null; 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 { } catch {
return null; return { nodeVersion: null, sqliteVersion: null, supported: false };
} }
} }
type SystemNodeInfo = { type SystemNodeInfo = {
path: string; path: string;
sqliteVersion: string | null;
version: string | null; version: string | null;
supported: boolean; supported: boolean;
}; };
@@ -172,11 +206,12 @@ export async function resolveSystemNodeInfo(params: {
if (await isVersionManagedRealNodePath(systemNode, platform)) { if (await isVersionManagedRealNodePath(systemNode, platform)) {
continue; continue;
} }
const version = await resolveNodeVersion(systemNode, execFileImpl); const runtime = await resolveNodeRuntimeInfo(systemNode, execFileImpl);
const info = { const info = {
path: systemNode, path: systemNode,
version, sqliteVersion: runtime.sqliteVersion,
supported: isSupportedNodeVersion(version), version: runtime.nodeVersion,
supported: runtime.supported,
}; };
if (info.supported) { if (info.supported) {
return info; return info;
@@ -196,7 +231,11 @@ export function renderSystemNodeWarning(
} }
const versionLabel = systemNode.version ?? "unknown"; const versionLabel = systemNode.version ?? "unknown";
const selectedLabel = selectedNodePath ? ` Using ${selectedNodePath} for the daemon.` : ""; 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. */ /** Resolves the Node binary the daemon should use for a node runtime. */
export async function resolvePreferredNodePath(params: { export async function resolvePreferredNodePath(params: {
@@ -214,8 +253,8 @@ export async function resolvePreferredNodePath(params: {
const currentExecPath = params.execPath ?? process.execPath; const currentExecPath = params.execPath ?? process.execPath;
const execFileImpl = params.execFile ?? execFileAsync; const execFileImpl = params.execFile ?? execFileAsync;
if (currentExecPath && isNodeExecPath(currentExecPath, platform)) { if (currentExecPath && isNodeExecPath(currentExecPath, platform)) {
const version = await resolveNodeVersion(currentExecPath, execFileImpl); const runtime = await resolveNodeRuntimeInfo(currentExecPath, execFileImpl);
if (isSupportedNodeVersion(version)) { if (runtime.supported) {
const stableCurrentPath = await resolveStableNodePath(currentExecPath); const stableCurrentPath = await resolveStableNodePath(currentExecPath);
if (!isVersionManagedNodePath(currentExecPath, platform)) { if (!isVersionManagedNodePath(currentExecPath, platform)) {
return stableCurrentPath; return stableCurrentPath;
+3
View File
@@ -97,6 +97,9 @@ describe("auditGatewayServiceConfig", () => {
}, },
}); });
expect(hasIssue(audit, SERVICE_AUDIT_CODES.gatewayRuntimeBun)).toBe(true); 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 () => { it("flags version-managed node paths", async () => {
+2 -2
View File
@@ -562,7 +562,7 @@ async function auditGatewayRuntime(
if (isBunRuntime(execPath)) { if (isBunRuntime(execPath)) {
issues.push({ issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeBun, 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, detail: execPath,
level: "recommended", level: "recommended",
}); });
@@ -586,7 +586,7 @@ async function auditGatewayRuntime(
issues.push({ issues.push({
code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing, code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing,
message: 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", level: "recommended",
}); });
} }
+2
View File
@@ -69,6 +69,8 @@ if (
ensureOpenClawExecMarkerOnProcess(); ensureOpenClawExecMarkerOnProcess();
installProcessWarningFilter(); installProcessWarningFilter();
normalizeEnv(); normalizeEnv();
const { assertSupportedRuntime } = await import("./infra/runtime-guard.js");
assertSupportedRuntime();
enableOpenClawCompileCache({ enableOpenClawCompileCache({
installRoot, installRoot,
@@ -199,7 +199,7 @@ describe("bootstrapWorker", () => {
const runner = fakeRunner([ const runner = fakeRunner([
result({ result({
code: 45, 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 }, { ssh: SSH, artifact: BUNDLE },
{ resolveIdentity, runCommand: runner.runCommand }, { 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).toHaveLength(1);
expect(runner.calls[0]?.options.input).toContain("process.versions.node"); 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 () => { 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; 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. // 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); 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])));
if (!match) process.exit(1); const node = parse(process.versions.node); if (!node) process.exit(1);
const major = Number(match[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;
const minor = Number(match[2]); if (!nodeSafe) process.exit(1);
const supported = try { const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(":memory:");
(major === 22 && minor >= 19) || const sqlite = parse(String(db.prepare("SELECT sqlite_version() AS version").get()?.version ?? ""));
(major === 23 && minor >= 11) || db.close(); if (!sqlite) process.exit(1);
major >= 24; const sqliteSafe = atLeast(sqlite, [3, 51, 3]) || (sqlite[0] === 3 && ((sqlite[1] === 50 && sqlite[2] >= 7) || (sqlite[1] === 44 && sqlite[2] >= 6)));
process.exit(supported ? 0 : 1);`; process.exit(sqliteSafe ? 0 : 1); } catch { process.exit(1); }`;
const RECEIPT_MATCH_JS = String.raw`const fs = require("node:fs"); const RECEIPT_MATCH_JS = String.raw`const fs = require("node:fs");
try { try {
@@ -248,7 +248,7 @@ if ! command -v node >/dev/null 2>&1; then
exit ${NODE_MISSING_EXIT_CODE} exit ${NODE_MISSING_EXIT_CODE}
fi fi
if ! node -e '${NODE_VERSION_CHECK_JS}'; then if ! node -e '${NODE_RUNTIME_CHECK_JS}'; then
printf '%s: ' '${NODE_UNSUPPORTED_MARKER}' >&2 printf '%s: ' '${NODE_UNSUPPORTED_MARKER}' >&2
node --version >&2 || true node --version >&2 || true
exit ${NODE_UNSUPPORTED_EXIT_CODE} exit ${NODE_UNSUPPORTED_EXIT_CODE}
@@ -651,7 +651,7 @@ function parsePreflight(
result.stdout.includes(NODE_UNSUPPORTED_MARKER) result.stdout.includes(NODE_UNSUPPORTED_MARKER)
) { ) {
throw new Error( 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)) { 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. // Loads node:sqlite with OpenClaw warning handling.
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { formatErrorMessage } from "./errors.js"; import { formatErrorMessage } from "./errors.js";
import { isSqliteWalResetSafeVersion } from "./sqlite-runtime-version.js";
import { installProcessWarningFilter } from "./warning-filter.js"; import { installProcessWarningFilter } from "./warning-filter.js";
const require = createRequire(import.meta.url); 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 // node:sqlite is optional across Node versions, so callers get a clear runtime
// error instead of a low-level module resolution failure. // 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") { export function requireNodeSqlite(): typeof import("node:sqlite") {
installProcessWarningFilter(); installProcessWarningFilter();
try { 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) { } catch (err) {
const message = formatErrorMessage(err); const message = formatErrorMessage(err);
throw new Error( throw new Error(`SQLite support is unavailable or unsafe in this Node runtime. ${message}`, {
`SQLite support is unavailable in this Node runtime (missing node:sqlite). ${message}`, cause: err,
{ cause: err }, });
);
} }
} }
+63 -25
View File
@@ -16,7 +16,7 @@ describe("runtime-guard", () => {
it("parses semver with or without leading v", () => { it("parses semver with or without leading v", () => {
expect(parseSemver("v22.1.3")).toEqual({ major: 22, minor: 1, patch: 3 }); 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("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(); expect(parseSemver("invalid")).toBeNull();
}); });
@@ -38,12 +38,18 @@ describe("runtime-guard", () => {
it("validates runtime thresholds", () => { it("validates runtime thresholds", () => {
const nodeOk: RuntimeDetails = { const nodeOk: RuntimeDetails = {
kind: "node", kind: "node",
version: "22.19.0", version: "22.22.3",
execPath: "/usr/bin/node", execPath: "/usr/bin/node",
pathEnv: "/usr/bin", 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 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 = { const unknown: RuntimeDetails = {
kind: "unknown", kind: "unknown",
version: null, version: null,
@@ -53,37 +59,42 @@ describe("runtime-guard", () => {
expect(runtimeSatisfies(nodeOk)).toBe(true); expect(runtimeSatisfies(nodeOk)).toBe(true);
expect(runtimeSatisfies(nodeOld)).toBe(false); expect(runtimeSatisfies(nodeOld)).toBe(false);
expect(runtimeSatisfies(nodeTooOld)).toBe(false); expect(runtimeSatisfies(nodeTooOld)).toBe(false);
expect(runtimeSatisfies(bun)).toBe(false);
expect(runtimeSatisfies(unknown)).toBe(false); expect(runtimeSatisfies(unknown)).toBe(false);
expect(isSupportedNodeVersion("22.19.0")).toBe(true); expect(isSupportedNodeVersion("22.22.3")).toBe(true);
expect(isSupportedNodeVersion("22.18.9")).toBe(false); expect(isSupportedNodeVersion("22.22.2")).toBe(false);
expect(isSupportedNodeVersion("23.7.0")).toBe(false); expect(isSupportedNodeVersion("23.11.0")).toBe(false);
expect(isSupportedNodeVersion("23.10.9")).toBe(false); expect(isSupportedNodeVersion("24.14.1")).toBe(false);
expect(isSupportedNodeVersion("23.11.0")).toBe(true); expect(isSupportedNodeVersion("24.15.0")).toBe(true);
expect(isSupportedNodeVersion("24.0.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); expect(isSupportedNodeVersion(null)).toBe(false);
}); });
it("parses simple minimum node engine ranges", () => { 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(" >=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", () => { it("checks node versions against simple engine ranges", () => {
expect(nodeVersionSatisfiesEngine("22.19.0", ">=22.19.0")).toBe(true); expect(nodeVersionSatisfiesEngine("22.22.3", ">=22.22.3")).toBe(true);
expect(nodeVersionSatisfiesEngine("22.18.9", ">=22.19.0")).toBe(false); expect(nodeVersionSatisfiesEngine("22.22.2", ">=22.22.3")).toBe(false);
expect(nodeVersionSatisfiesEngine("24.0.0", ">=22.19.0")).toBe(true); expect(nodeVersionSatisfiesEngine("24.15.0", ">=22.22.3")).toBe(true);
expect(nodeVersionSatisfiesEngine("22.19.0", "^22.19.0")).toBeNull(); expect(nodeVersionSatisfiesEngine("22.22.3", "^22.22.3")).toBeNull();
}); });
it("checks node versions against the supported engine range", () => { it("checks node versions against the supported engine range", () => {
const engine = ">=22.19.0 <23 || >=23.11.0"; const engine = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0";
expect(nodeVersionSatisfiesEngine("22.19.0", engine)).toBe(true); expect(nodeVersionSatisfiesEngine("22.22.3", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine("22.18.9", engine)).toBe(false); expect(nodeVersionSatisfiesEngine("22.22.2", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("23.7.0", engine)).toBe(false); expect(nodeVersionSatisfiesEngine("23.11.0", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("23.10.9", engine)).toBe(false); expect(nodeVersionSatisfiesEngine("24.14.1", engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("23.11.0", engine)).toBe(true); expect(nodeVersionSatisfiesEngine("24.15.0", engine)).toBe(true);
expect(nodeVersionSatisfiesEngine("24.0.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(null, engine)).toBe(false);
expect(nodeVersionSatisfiesEngine("unknown", engine)).toBe(false); expect(nodeVersionSatisfiesEngine("unknown", engine)).toBe(false);
}); });
@@ -106,7 +117,7 @@ describe("runtime-guard", () => {
expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith( 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).", "Detected: node 20.0.0 (exec: /usr/bin/node).",
"PATH searched: /usr/bin", "PATH searched: /usr/bin",
"Install Node: https://nodejs.org/en/download", "Install Node: https://nodejs.org/en/download",
@@ -125,13 +136,40 @@ describe("runtime-guard", () => {
const details: RuntimeDetails = { const details: RuntimeDetails = {
...detectRuntime(), ...detectRuntime(),
kind: "node", kind: "node",
version: "22.19.0", version: "22.22.3",
execPath: "/usr/bin/node", execPath: "/usr/bin/node",
}; };
expect(assertSupportedRuntime(runtime, details)).toBeUndefined(); expect(assertSupportedRuntime(runtime, details)).toBeUndefined();
expect(runtime.exit).not.toHaveBeenCalled(); 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", () => { it("reports unknown runtimes with fallback labels", () => {
const runtime = { const runtime = {
log: vi.fn(), log: vi.fn(),
@@ -151,7 +189,7 @@ describe("runtime-guard", () => {
expect(runtime.error).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith( 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).", "Detected: unknown runtime (exec: unknown).",
"PATH searched: (not set)", "PATH searched: (not set)",
"Install Node: https://nodejs.org/en/download", "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 { expectDefined } from "@openclaw/normalization-core";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
type RuntimeKind = "node" | "unknown"; type RuntimeKind = "bun" | "node" | "unknown";
type Semver = { type Semver = {
major: number; major: number;
@@ -11,11 +11,11 @@ type Semver = {
patch: number; patch: number;
}; };
const MIN_NODE_22: Semver = { major: 22, minor: 19, patch: 0 }; const MIN_NODE_22: Semver = { major: 22, minor: 22, patch: 3 };
const MIN_NODE_23: Semver = { major: 23, minor: 11, patch: 0 }; 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 MINIMUM_ENGINE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i;
const DISJUNCTIVE_ENGINE_RE = const ENGINE_CLAUSE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)(?:\s+<\s*v?(\d+(?:\.\d+\.\d+)?))?\s*$/i;
/^\s*>=\s*v?(\d+\.\d+\.\d+)\s+<\s*v?(\d+)(?:\.(\d+)\.(\d+))?\s*\|\|\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i;
/** Runtime facts included in startup/runtime-version diagnostics. */ /** Runtime facts included in startup/runtime-version diagnostics. */
export type RuntimeDetails = { 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. */ /** Reads current process runtime metadata for startup support checks. */
export function detectRuntime(): RuntimeDetails { export function detectRuntime(): RuntimeDetails {
const kind: RuntimeKind = process.versions?.node ? "node" : "unknown"; const bunVersion = process.versions?.bun;
const version = process.versions?.node ?? null; const kind: RuntimeKind = bunVersion ? "bun" : process.versions?.node ? "node" : "unknown";
const version = bunVersion ?? process.versions?.node ?? null;
return { return {
kind, kind,
@@ -88,10 +89,13 @@ export function isSupportedNodeVersion(version: string | null): boolean {
if (parsed.major === MIN_NODE_22.major) { if (parsed.major === MIN_NODE_22.major) {
return isAtLeast(parsed, MIN_NODE_22); return isAtLeast(parsed, MIN_NODE_22);
} }
if (parsed.major === MIN_NODE_23.major) { if (parsed.major === MIN_NODE_24.major) {
return isAtLeast(parsed, MIN_NODE_23); 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`. */ /** Parses simple package `engines.node` ranges of the form `>=x.y.z`. */
@@ -116,30 +120,34 @@ export function nodeVersionSatisfiesEngine(
return isAtLeast(parseSemver(version), minimum); return isAtLeast(parseSemver(version), minimum);
} }
const rangeMatch = engine?.match(DISJUNCTIVE_ENGINE_RE); if (!engine) {
if (!rangeMatch) {
return null; return null;
} }
const parsed = parseSemver(version); const parsed = parseSemver(version);
if (!parsed) { if (!parsed) {
return false; return false;
} }
const [, firstMinimumRaw, upperMajorRaw, upperMinorRaw, upperPatchRaw, secondMinimumRaw] =
rangeMatch; const clauses = engine.split("||");
const firstMinimum = parseSemver(firstMinimumRaw ?? null); let satisfied = false;
const secondMinimum = parseSemver(secondMinimumRaw ?? null); for (const clause of clauses) {
const upperBound: Semver = { const match = clause.match(ENGINE_CLAUSE_RE);
major: Number.parseInt(upperMajorRaw ?? "", 10), if (!match) {
minor: Number.parseInt(upperMinorRaw ?? "0", 10), return null;
patch: Number.parseInt(upperPatchRaw ?? "0", 10), }
}; const clauseMinimum = parseSemver(match[1] ?? null);
if (!firstMinimum || !secondMinimum || !Number.isFinite(upperBound.major)) { const upperRaw = match[2];
return null; 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 ( return satisfied;
(isAtLeast(parsed, firstMinimum) && !isAtLeast(parsed, upperBound)) ||
isAtLeast(parsed, secondMinimum)
);
} }
/** Exits through the provided runtime when the current Node runtime is unsupported. */ /** Exits through the provided runtime when the current Node runtime is unsupported. */
@@ -155,14 +163,22 @@ export function assertSupportedRuntime(
const runtimeLabel = const runtimeLabel =
details.kind === "unknown" ? "unknown runtime" : `${details.kind} ${versionLabel}`; details.kind === "unknown" ? "unknown runtime" : `${details.kind} ${versionLabel}`;
const execLabel = details.execPath ?? "unknown"; 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( runtime.error(
[ [
"openclaw requires Node >=22.19.0 <23 or >=23.11.0.", requirement,
`Detected: ${runtimeLabel} (exec: ${execLabel}).`, `Detected: ${runtimeLabel} (exec: ${execLabel}).`,
`PATH searched: ${details.pathEnv}`, `PATH searched: ${details.pathEnv}`,
"Install Node: https://nodejs.org/en/download", "Install Node: https://nodejs.org/en/download",
"Upgrade Node and re-run openclaw.", retryHint,
].join("\n"), ].join("\n"),
); );
runtime.exit(1); 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. // table-valued pragma name and make a corrupt database appear clean.
const statement = database.prepare("PRAGMA foreign_key_check;"); const statement = database.prepare("PRAGMA foreign_key_check;");
statement.setReadBigInts(true); 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>) { for (const violation of statement.iterate() as Iterable<SqliteForeignKeyViolation>) {
violationCount += 1; violationCount += 1;
retainSortedForeignKeyViolation(violations, violation); 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 () => { it("prefers the Homebrew opt symlink for default and versioned formulas", async () => {
await withTempDir({ prefix: "openclaw-stable-node-" }, async (prefix) => { await withTempDir({ prefix: "openclaw-stable-node-" }, async (prefix) => {
const defaultNode = path.join(prefix, "Cellar", "node", "25.7.0", "bin", "node"); const defaultNode = path.join(prefix, "Cellar", "node", "25.9.0", "bin", "node");
const versionedNode = path.join(prefix, "Cellar", "node@22", "22.19.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 optDefault = path.join(prefix, "opt", "node", "bin", "node");
const optVersioned = path.join(prefix, "opt", "node@22", "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 () => { it("falls back to the bin symlink for the default formula, otherwise original path", async () => {
await withTempDir({ prefix: "openclaw-stable-node-" }, async (prefix) => { await withTempDir({ prefix: "openclaw-stable-node-" }, async (prefix) => {
const defaultNode = path.join(prefix, "Cellar", "node", "25.7.0", "bin", "node"); const defaultNode = path.join(prefix, "Cellar", "node", "25.9.0", "bin", "node");
const versionedNode = path.join(prefix, "Cellar", "node@22", "22.19.0", "bin", "node"); const versionedNode = path.join(prefix, "Cellar", "node@22", "22.22.3", "bin", "node");
const binNode = path.join(prefix, "bin", "node"); const binNode = path.join(prefix, "bin", "node");
await fs.mkdir(path.dirname(binNode), { recursive: true }); await fs.mkdir(path.dirname(binNode), { recursive: true });
+1 -1
View File
@@ -1000,7 +1000,7 @@ export const en = {
daemonRuntime: "Gateway service runtime", daemonRuntime: "Gateway service runtime",
daemonRuntimeNode: "Node (recommended)", daemonRuntimeNode: "Node (recommended)",
daemonRuntimeNodeHint: 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.", editBootstrap: "Edit BOOTSTRAP.md later to change how the agent introduces itself.",
bootstrapHatchMessage: "Wake up, my friend!", bootstrapHatchMessage: "Wake up, my friend!",
firstTerminalChat: 'The first Terminal chat run will send: "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}", dashboardWhenReady: "准备好后运行:{command}",
daemonRuntime: "Gateway 服务运行时", daemonRuntime: "Gateway 服务运行时",
daemonRuntimeNode: "Node(推荐)", daemonRuntimeNode: "Node(推荐)",
daemonRuntimeNodeHint: "WhatsApp + Telegram 需要使用。Bun 重新连接时可能造成内存损坏。", daemonRuntimeNodeHint: "OpenClaw 状态使用 node:sqlite,因此必须使用 NodeBun 无法运行网关。",
editBootstrap: "之后可编辑 BOOTSTRAP.md 来修改 agent 的自我介绍方式。", editBootstrap: "之后可编辑 BOOTSTRAP.md 来修改 agent 的自我介绍方式。",
bootstrapHatchMessage: "醒醒,我的朋友!", bootstrapHatchMessage: "醒醒,我的朋友!",
firstTerminalChat: '第一次终端聊天会发送:"醒醒,我的朋友!"', firstTerminalChat: '第一次终端聊天会发送:"醒醒,我的朋友!"',
+1 -1
View File
@@ -969,7 +969,7 @@ export const zh_TW = {
dashboardWhenReady: "準備好後執行:{command}", dashboardWhenReady: "準備好後執行:{command}",
daemonRuntime: "Gateway 服務執行環境", daemonRuntime: "Gateway 服務執行環境",
daemonRuntimeNode: "Node(建議)", daemonRuntimeNode: "Node(建議)",
daemonRuntimeNodeHint: "WhatsApp + Telegram 需要使用。Bun 重新連線時可能造成記憶體損壞。", daemonRuntimeNodeHint: "OpenClaw 狀態使用 node:sqlite,因此必須使用 NodeBun 無法執行閘道。",
editBootstrap: "之後可編輯 BOOTSTRAP.md 來修改 agent 的自我介紹方式。", editBootstrap: "之後可編輯 BOOTSTRAP.md 來修改 agent 的自我介紹方式。",
bootstrapHatchMessage: "醒醒,我的朋友!", bootstrapHatchMessage: "醒醒,我的朋友!",
firstTerminalChat: '第一次終端機聊天會傳送:"醒醒,我的朋友!"', firstTerminalChat: '第一次終端機聊天會傳送:"醒醒,我的朋友!"',
+2 -2
View File
@@ -4606,7 +4606,7 @@ surfaces:
description: Hosted installer and local-prefix install paths on macOS description: Hosted installer and local-prefix install paths on macOS
- name: Node 24 recommendation - name: Node 24 recommendation
coverageIds: [macos.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 - name: App-triggered CLI install
coverageIds: [macos.app-triggered-cli-install] coverageIds: [macos.app-triggered-cli-install]
description: App-triggered CLI install and runtime discovery description: App-triggered CLI install and runtime discovery
@@ -5423,7 +5423,7 @@ surfaces:
description: WSL2 and Ubuntu installation requirements. description: WSL2 and Ubuntu installation requirements.
- name: Node runtime - name: Node runtime
coverageIds: [wsl2.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 - name: Linux install flow inside WSL2
coverageIds: [wsl2.linux-install-flow-inside-wsl2] coverageIds: [wsl2.linux-install-flow-inside-wsl2]
description: Linux install and getting-started flow run 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 () => { 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 packageJsonRaw = await fs.readFile(path.resolve(process.cwd(), "package.json"), "utf8");
const packageJson = JSON.parse(packageJsonRaw) as { engines?: { node?: string } }; 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); const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile( await fs.writeFile(
@@ -176,12 +176,14 @@ describe("openclaw launcher", () => {
); );
const cases = [ const cases = [
{ version: "22.18.9", supported: false }, { version: "22.22.2", supported: false },
{ version: "22.19.0", supported: true }, { version: "22.22.3", supported: true },
{ version: "23.7.0", supported: false }, { version: "23.11.0", supported: false },
{ version: "23.10.9", supported: false }, { version: "24.14.1", supported: false },
{ version: "23.11.0", supported: true }, { version: "24.15.0", supported: true },
{ version: "24.0.0", supported: true }, { version: "25.8.1", supported: false },
{ version: "25.9.0", supported: true },
{ version: "26.0.0", supported: true },
] as const; ] as const;
for (const testCase of cases) { for (const testCase of cases) {
@@ -220,12 +222,46 @@ describe("openclaw launcher", () => {
} else { } else {
expect(result.status, testCase.version).toBe(1); expect(result.status, testCase.version).toBe(1);
expect(result.stderr, testCase.version).toContain( 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 () => { it("surfaces transitive entry import failures instead of masking them as missing dist", async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots); const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile( await fs.writeFile(
@@ -382,12 +418,12 @@ describe("openclaw launcher", () => {
}); });
it.runIf(process.env.OPENCLAW_TEST_BUN_LAUNCHER === "1" && hasBunRuntime())( 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 () => { async () => {
const fixtureRoot = await makeLauncherFixture(fixtureRoots); const fixtureRoot = await makeLauncherFixture(fixtureRoots);
await fs.writeFile( await fs.writeFile(
path.join(fixtureRoot, "dist", "entry.js"), path.join(fixtureRoot, "dist", "entry.js"),
"process.stdout.write('bun entry ok\\n');\n", "process.stdout.write('unexpected bun entry\\n');\n",
"utf8", "utf8",
); );
@@ -401,39 +437,11 @@ describe("openclaw launcher", () => {
}, },
); );
expect(result.status).toBe(0); expect(result.status).toBe(1);
expect(result.stdout).toBe("bun entry ok\n"); expect(result.stdout).toBe("");
}, expect(result.stderr).toContain(
); "the Bun runtime is unsupported because OpenClaw requires node:sqlite",
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",
); );
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")); 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 () => { it("keeps compile cache enabled for unaffected packaged launcher runtimes", async () => {
const cases: Array<{ nodeVersion: string; platform: NodeJS.Platform }> = [ const cases: Array<{ nodeVersion: string; platform: NodeJS.Platform }> = [
{ nodeVersion: "24.15.0", platform: "win32" }, { nodeVersion: "24.15.0", platform: "win32" },
{ nodeVersion: "24.1.0", platform: "linux" }, { nodeVersion: "22.22.3", platform: "linux" },
{ nodeVersion: "24.14.0", platform: "darwin" }, { nodeVersion: "25.9.0", platform: "darwin" },
]; ];
for (const runtime of cases) { for (const runtime of cases) {
@@ -53,7 +53,7 @@ describe("check-cli-startup-memory", () => {
}); });
it("guards packaged plugin listing 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.resolveDefaultLimitsMb("darwin").pluginsList).toBeGreaterThan(350);
expect(testing.cases).toContainEqual( expect(testing.cases).toContainEqual(
expect.objectContaining({ expect.objectContaining({
+1 -1
View File
@@ -2172,7 +2172,7 @@ describe("ci workflow guards", () => {
); );
expect(startupMemoryStep.env.OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB).toBe( 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"'); 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(` const result = runInstallCliShell(`
set -euo pipefail set -euo pipefail
source "${SCRIPT_PATH}" source "${SCRIPT_PATH}"
set +e 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" node_version_is_supported "$version"
printf '%s=%s\n' "$version" "$?" printf '%s=%s\n' "$version" "$?"
done done
`); `);
expect(result.status).toBe(0); expect(result.status).toBe(0);
expect(result.stdout).toContain("22.18.9=1"); expect(result.stdout).toContain("22.22.2=1");
expect(result.stdout).toContain("22.19.0=0"); expect(result.stdout).toContain("22.22.3=0");
expect(result.stdout).toContain("23.7.0=1"); expect(result.stdout).toContain("23.11.0=1");
expect(result.stdout).toContain("23.10.9=1"); expect(result.stdout).toContain("24.14.1=1");
expect(result.stdout).toContain("23.11.0=0"); expect(result.stdout).toContain("24.15.0=0");
expect(result.stdout).toContain("24.0.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(` const result = runInstallCliShell(`
set -euo pipefail set -euo pipefail
source "${SCRIPT_PATH}" 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 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 install_node
`); `);
expect(result.status).toBe(1); expect(result.status).toBe(1);
expect(result.stdout).toContain( 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", () => { it("rejects installer options with missing values", () => {
@@ -274,7 +343,7 @@ describe("install-cli.sh", () => {
[ [
"#!/bin/bash", "#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then', 'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.2\\n'", " printf 'v22.22.3\\n'",
" exit 0", " exit 0",
"fi", "fi",
'if [[ "${1:-}" == "-e" ]]; then', 'if [[ "${1:-}" == "-e" ]]; then',
@@ -302,7 +371,6 @@ describe("install-cli.sh", () => {
"is_root() { return 1; }", "is_root() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`, `PREFIX=${JSON.stringify(prefix)}`,
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
"NODE_VERSION=22.22.0",
"install_node", "install_node",
].join("\n"), ].join("\n"),
{ {
@@ -314,8 +382,8 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(0); expect(result.status).toBe(0);
expect(result.stdout).not.toContain("Installing Node via apk"); expect(result.stdout).not.toContain("Installing Node via apk");
expect(() => readFileSync(apkLog, "utf8")).toThrow(); expect(() => readFileSync(apkLog, "utf8")).toThrow();
const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); const nodeLink = join(prefix, "tools", "node-v24.15.0", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); const npmLink = join(prefix, "tools", "node-v24.15.0", "bin", "npm");
expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(nodeLink)).toBe(fakeNode); expect(readlinkSync(nodeLink)).toBe(fakeNode);
expect(readlinkSync(npmLink)).toBe(fakeNpm); expect(readlinkSync(npmLink)).toBe(fakeNpm);
@@ -330,7 +398,7 @@ describe("install-cli.sh", () => {
const bin = join(tmp, "bin"); const bin = join(tmp, "bin");
const oldBin = join(tmp, "old-bin"); const oldBin = join(tmp, "old-bin");
const prefix = join(tmp, "prefix"); 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 apkLog = join(tmp, "apk.log");
const fakeApk = join(bin, "apk"); const fakeApk = join(bin, "apk");
const fakeNode = join(bin, "node"); const fakeNode = join(bin, "node");
@@ -352,7 +420,7 @@ describe("install-cli.sh", () => {
[ [
"#!/bin/bash", "#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then', 'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.0\\n'", " printf 'v22.22.3\\n'",
" exit 0", " exit 0",
"fi", "fi",
'if [[ "${1:-}" == "-e" ]]; then', 'if [[ "${1:-}" == "-e" ]]; then',
@@ -367,7 +435,7 @@ describe("install-cli.sh", () => {
[ [
"#!/bin/bash", "#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then', 'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.2\\n'", " printf 'v22.22.3\\n'",
" exit 0", " exit 0",
"fi", "fi",
'if [[ "${1:-}" == "-e" ]]; then', 'if [[ "${1:-}" == "-e" ]]; then',
@@ -413,8 +481,7 @@ describe("install-cli.sh", () => {
"is_musl_linux() { return 0; }", "is_musl_linux() { return 0; }",
"is_root() { return 1; }", "is_root() { return 1; }",
`PREFIX=${JSON.stringify(prefix)}`, `PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.22.0", "NODE_VERSION=22.22.3",
"NODE_VERSION_REQUESTED=1",
"install_node", "install_node",
].join("\n"), ].join("\n"),
{ {
@@ -426,8 +493,8 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(0); expect(result.status).toBe(0);
expect(result.stdout).not.toContain("Installing Node via apk"); expect(result.stdout).not.toContain("Installing Node via apk");
expect(() => readFileSync(apkLog, "utf8")).toThrow(); expect(() => readFileSync(apkLog, "utf8")).toThrow();
const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); const nodeLink = join(prefix, "tools", "node-v22.22.3", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); const npmLink = join(prefix, "tools", "node-v22.22.3", "bin", "npm");
expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(nodeLink)).toBe(fakeNode); expect(readlinkSync(nodeLink)).toBe(fakeNode);
expect(readlinkSync(npmLink)).toBe(fakeNpm); expect(readlinkSync(npmLink)).toBe(fakeNpm);
@@ -464,7 +531,7 @@ describe("install-cli.sh", () => {
"#!/bin/bash", "#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then', 'if [[ "${1:-}" == "-v" ]]; then',
' if [[ -f "$NODE_STATE" ]]; then', ' if [[ -f "$NODE_STATE" ]]; then',
" printf 'v22.22.2\\n'", " printf 'v22.22.3\\n'",
" else", " else",
" printf 'v18.20.0\\n'", " printf 'v18.20.0\\n'",
" fi", " fi",
@@ -496,8 +563,7 @@ describe("install-cli.sh", () => {
"is_root() { return 0; }", "is_root() { return 0; }",
`PREFIX=${JSON.stringify(prefix)}`, `PREFIX=${JSON.stringify(prefix)}`,
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
"NODE_VERSION=22.22.0", "NODE_VERSION=22.22.3",
"NODE_VERSION_REQUESTED=1",
"install_node", "install_node",
].join("\n"), ].join("\n"),
{ {
@@ -510,8 +576,8 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(0); expect(result.status).toBe(0);
expect(result.stdout).toContain("Installing Node via apk"); expect(result.stdout).toContain("Installing Node via apk");
expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm"); expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm");
const nodeLink = join(prefix, "tools", "node-v22.22.0", "bin", "node"); const nodeLink = join(prefix, "tools", "node-v22.22.3", "bin", "node");
const npmLink = join(prefix, "tools", "node-v22.22.0", "bin", "npm"); const npmLink = join(prefix, "tools", "node-v22.22.3", "bin", "npm");
expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true); expect(lstatSync(nodeLink).isSymbolicLink()).toBe(true);
expect(readlinkSync(nodeLink)).toBe(fakeNode); expect(readlinkSync(nodeLink)).toBe(fakeNode);
expect(readlinkSync(npmLink)).toBe(fakeNpm); expect(readlinkSync(npmLink)).toBe(fakeNpm);
@@ -569,8 +635,7 @@ describe("install-cli.sh", () => {
"is_root() { return 0; }", "is_root() { return 0; }",
`PREFIX=${JSON.stringify(prefix)}`, `PREFIX=${JSON.stringify(prefix)}`,
`APK_NODE_BIN_DIR=${JSON.stringify(bin)}`, `APK_NODE_BIN_DIR=${JSON.stringify(bin)}`,
"NODE_VERSION=22.22.0", "NODE_VERSION=22.22.3",
"NODE_VERSION_REQUESTED=1",
"install_node", "install_node",
].join("\n"), ].join("\n"),
{ {
@@ -582,9 +647,9 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(1); expect(result.status).toBe(1);
expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm"); expect(readFileSync(apkLog, "utf8")).toContain("add --no-cache nodejs npm");
expect(result.stdout).toContain( 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 { } finally {
rmSync(tmp, { force: true, recursive: true }); rmSync(tmp, { force: true, recursive: true });
} }
@@ -593,7 +658,7 @@ describe("install-cli.sh", () => {
it("replaces cached generic Node runtimes below the runtime floor", () => { it("replaces cached generic Node runtimes below the runtime floor", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-generic-stale-node-")); const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-generic-stale-node-"));
const prefix = join(tmp, "prefix"); 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 staleNode = join(nodePrefixBin, "node");
const staleNpm = join(nodePrefixBin, "npm"); const staleNpm = join(nodePrefixBin, "npm");
const newNode = join(tmp, "new-node"); const newNode = join(tmp, "new-node");
@@ -621,7 +686,7 @@ describe("install-cli.sh", () => {
[ [
"#!/bin/bash", "#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then', 'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.22.0\\n'", " printf 'v22.22.3\\n'",
" exit 0", " exit 0",
"fi", "fi",
'if [[ "${1:-}" == "-e" ]]; then', 'if [[ "${1:-}" == "-e" ]]; then',
@@ -650,7 +715,7 @@ describe("install-cli.sh", () => {
"require_bin() { :; }", "require_bin() { :; }",
"download_file() {", "download_file() {",
' case "$1" in', ' 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\" ;;", " *) printf 'node tarball fixture\\n' > \"$2\" ;;",
" esac", " esac",
"}", "}",
@@ -665,8 +730,7 @@ describe("install-cli.sh", () => {
' cp "$NEW_NPM" "$dest/bin/npm"', ' cp "$NEW_NPM" "$dest/bin/npm"',
"}", "}",
`PREFIX=${JSON.stringify(prefix)}`, `PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.22.0", "NODE_VERSION=22.22.3",
"NODE_VERSION_REQUESTED=1",
"install_node", "install_node",
].join("\n"), ].join("\n"),
{ {
@@ -676,9 +740,9 @@ describe("install-cli.sh", () => {
); );
expect(result.status).toBe(0); 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(result.stdout).not.toContain('"status":"skip"');
expect(readFileSync(staleNode, "utf8")).toContain("v22.22.0"); expect(readFileSync(staleNode, "utf8")).toContain("v22.22.3");
} finally { } finally {
rmSync(tmp, { force: true, recursive: true }); rmSync(tmp, { force: true, recursive: true });
} }
@@ -695,7 +759,7 @@ describe("install-cli.sh", () => {
[ [
"#!/bin/bash", "#!/bin/bash",
'if [[ "${1:-}" == "-v" ]]; then', 'if [[ "${1:-}" == "-v" ]]; then',
" printf 'v22.18.0\\n'", " printf 'v22.22.2\\n'",
" exit 0", " exit 0",
"fi", "fi",
'if [[ "${1:-}" == "-e" ]]; then', 'if [[ "${1:-}" == "-e" ]]; then',
@@ -722,7 +786,7 @@ describe("install-cli.sh", () => {
"require_bin() { :; }", "require_bin() { :; }",
"download_file() {", "download_file() {",
' case "$1" in', ' 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\" ;;", " *) printf 'node tarball fixture\\n' > \"$2\" ;;",
" esac", " esac",
"}", "}",
@@ -737,8 +801,7 @@ describe("install-cli.sh", () => {
' cp "$NEW_NPM" "$dest/bin/npm"', ' cp "$NEW_NPM" "$dest/bin/npm"',
"}", "}",
`PREFIX=${JSON.stringify(prefix)}`, `PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.19.0", "NODE_VERSION=22.22.3",
"NODE_VERSION_REQUESTED=1",
"install_node", "install_node",
].join("\n"), ].join("\n"),
{ {
@@ -749,9 +812,9 @@ describe("install-cli.sh", () => {
expect(result.status).toBe(1); expect(result.status).toBe(1);
expect(result.stdout).toContain( 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 { } finally {
rmSync(tmp, { force: true, recursive: true }); 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)}; }`, `mktemp() { mkdir -p ${JSON.stringify(stagingDir)}; printf '%s\\n' ${JSON.stringify(stagingDir)}; }`,
"download_file() { return 42; }", "download_file() { return 42; }",
`PREFIX=${JSON.stringify(prefix)}`, `PREFIX=${JSON.stringify(prefix)}`,
"NODE_VERSION=22.22.0", "NODE_VERSION=22.22.3",
"install_node", "install_node",
].join("\n"), ].join("\n"),
); );
@@ -1060,7 +1123,7 @@ describe("install-cli.sh", () => {
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-freshness-")); const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-freshness-"));
const prefix = join(tmp, "prefix"); const prefix = join(tmp, "prefix");
const home = join(tmp, "home"); 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"); const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true }); mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true }); mkdirSync(home, { recursive: true });
@@ -1096,7 +1159,7 @@ describe("install-cli.sh", () => {
const prefix = join(tmp, "prefix"); const prefix = join(tmp, "prefix");
const home = join(tmp, "home"); const home = join(tmp, "home");
const project = join(tmp, "project"); 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"); const argsLog = join(tmp, "npm-args.log");
mkdirSync(nodeBin, { recursive: true }); mkdirSync(nodeBin, { recursive: true });
mkdirSync(home, { recursive: true }); mkdirSync(home, { recursive: true });
+102 -10
View File
@@ -110,12 +110,14 @@ describe("install.ps1 failure handling", () => {
scriptWithoutEntryPoint, scriptWithoutEntryPoint,
"", "",
"$cases = @{", "$cases = @{",
" '22.18.9' = $false", " '22.22.2' = $false",
" '22.19.0' = $true", " '22.22.3' = $true",
" '23.7.0' = $false", " '23.11.0' = $false",
" '23.10.9' = $false", " '24.14.1' = $false",
" '23.11.0' = $true", " '24.15.0' = $true",
" '24.0.0' = $true", " '25.8.1' = $false",
" '25.9.0' = $true",
" '26.0.0' = $true",
"}", "}",
"foreach ($entry in $cases.GetEnumerator()) {", "foreach ($entry in $cases.GetEnumerator()) {",
" $actual = Test-NodeVersionSupported -Version $entry.Key", " $actual = Test-NodeVersionSupported -Version $entry.Key",
@@ -201,6 +203,83 @@ describe("install.ps1 failure handling", () => {
"", "",
].join("\n"), ].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", name: "scriptblock-failure",
source: [ source: [
@@ -347,19 +426,32 @@ describe("install.ps1 failure handling", () => {
it("checks the full supported Node version range", () => { it("checks the full supported Node version range", () => {
const versionBody = extractFunctionBody(source, "Test-NodeVersionSupported"); const versionBody = extractFunctionBody(source, "Test-NodeVersionSupported");
const sqliteBody = extractFunctionBody(source, "Test-NodeSqliteSupported");
const checkNodeBody = extractFunctionBody(source, "Check-Node"); const checkNodeBody = extractFunctionBody(source, "Check-Node");
expect(versionBody).toContain("$major -eq 22"); expect(versionBody).toContain("$major -eq 22");
expect(versionBody).toContain("$minor -ge 19"); expect(versionBody).toContain("$patch -ge 3");
expect(versionBody).toContain("$major -eq 23"); expect(versionBody).toContain("$major -eq 24");
expect(versionBody).toContain("$minor -ge 11"); expect(versionBody).toContain("$minor -ge 15");
expect(versionBody).toContain("$major -gt 23"); 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-NodeVersionSupported -Version $nodeVersion");
expect(checkNodeBody).toContain("Test-NodeSqliteSupported");
expect(source).toContain("Please install Node.js 24.15+ manually:");
}); });
runIfPowerShell("accepts only supported Node versions", () => { runIfPowerShell("accepts only supported Node versions", () => {
expectBatchedPowerShellCase("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", () => { it("runs npm install through the resolved command with quiet CI defaults", () => {
const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw"); const npmInstallBody = extractFunctionBody(source, "Install-OpenClaw");
expect(npmInstallBody).toContain("$npmOutput = Invoke-NpmCommand -Arguments"); expect(npmInstallBody).toContain("$npmOutput = Invoke-NpmCommand -Arguments");
+46 -20
View File
@@ -298,7 +298,7 @@ NODE
apk() { apk() {
printf 'apk:%s\\n' "$*" printf 'apk:%s\\n' "$*"
if [[ "$*" == *"nodejs-current"* ]]; then if [[ "$*" == *"nodejs-current"* ]]; then
NODE_FAKE_VERSION=v22.22.2 NODE_FAKE_VERSION=v22.22.3
fi fi
} }
node() { node() {
@@ -320,7 +320,7 @@ NODE
expect(result.stdout).toContain("finish-linux-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(` const result = runInstallShell(`
set -euo pipefail set -euo pipefail
source "${SCRIPT_PATH}" source "${SCRIPT_PATH}"
@@ -359,9 +359,11 @@ NODE
"step:Installing nodejs-current|apk add --no-cache nodejs-current npm", "step:Installing nodejs-current|apk add --no-cache nodejs-current npm",
); );
expect(result.stdout).toContain( 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", () => { it("stops when NodeSource repository setup fails", () => {
@@ -1260,7 +1262,7 @@ NODE
const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-nvm-")); const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-nvm-"));
const home = join(tmp, "home"); const home = join(tmp, "home");
const systemBin = join(tmp, "system-bin"); 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(systemBin, { recursive: true });
mkdirSync(nvmBin, { recursive: true }); mkdirSync(nvmBin, { recursive: true });
mkdirSync(join(home, ".nvm"), { recursive: true }); mkdirSync(join(home, ".nvm"), { recursive: true });
@@ -1268,7 +1270,7 @@ NODE
const systemNode = join(systemBin, "node"); const systemNode = join(systemBin, "node");
const nvmNode = join(nvmBin, "node"); const nvmNode = join(nvmBin, "node");
writeFileSync(systemNode, "#!/bin/sh\necho v8.11.3\n"); 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(systemNode, 0o755);
chmodSync(nvmNode, 0o755); chmodSync(nvmNode, 0o755);
writeFileSync( writeFileSync(
@@ -1278,7 +1280,7 @@ NODE
"export NVM_DIR", "export NVM_DIR",
"nvm() {", "nvm() {",
' if [ "$1" = "use" ]; then', ' 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", " return 0",
" fi", " fi",
" return 0", " return 0",
@@ -1315,7 +1317,7 @@ NODE
const output = result?.stdout ?? ""; const output = result?.stdout ?? "";
expect(output).toContain("status=0"); expect(output).toContain("status=0");
expect(output).toContain(`path=${nvmNode}`); 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", () => { it("installs Homebrew lazily before macOS Git installs", () => {
@@ -1344,7 +1346,7 @@ NODE
const staleNode = join(staleBin, "node"); const staleNode = join(staleBin, "node");
const supportedNode = join(supportedBin, "node"); const supportedNode = join(supportedBin, "node");
writeFileSync(staleNode, "#!/bin/sh\necho v20.20.0\n"); 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(staleNode, 0o755);
chmodSync(supportedNode, 0o755); chmodSync(supportedNode, 0o755);
@@ -1384,14 +1386,14 @@ NODE
expect(output).toContain("promote=0"); expect(output).toContain("promote=0");
expect(output).toContain("active=0"); expect(output).toContain("active=0");
expect(output).toContain(`path=${supportedNode}`); 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", () => { it("uses the package engine range when accepting existing Node runtimes", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { const pkg = JSON.parse(readFileSync("package.json", "utf8")) as {
engines?: { node?: string }; 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 tmp = mkdtempSync(join(tmpdir(), "openclaw-install-node-floor-"));
const bin = join(tmp, "bin"); const bin = join(tmp, "bin");
@@ -1416,7 +1418,7 @@ NODE
"unset -f node 2>/dev/null || true", "unset -f node 2>/dev/null || true",
"unalias node 2>/dev/null || true", "unalias node 2>/dev/null || true",
'node() { printf "%s\\n" "${FAKE_NODE_VERSION:-v0.0.0}"; }', '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}"', ' FAKE_NODE_VERSION="v${version}"',
" export FAKE_NODE_VERSION", " export FAKE_NODE_VERSION",
" node_is_supported", " node_is_supported",
@@ -1434,12 +1436,36 @@ NODE
} }
expect(result?.status).toBe(0); expect(result?.status).toBe(0);
expect(result?.stdout).toContain("22.18.9=1"); expect(result?.stdout).toContain("22.22.2=1");
expect(result?.stdout).toContain("22.19.0=0"); expect(result?.stdout).toContain("22.22.3=0");
expect(result?.stdout).toContain("23.7.0=1"); expect(result?.stdout).toContain("23.11.0=1");
expect(result?.stdout).toContain("23.10.9=1"); expect(result?.stdout).toContain("24.14.1=1");
expect(result?.stdout).toContain("23.11.0=0"); expect(result?.stdout).toContain("24.15.0=0");
expect(result?.stdout).toContain("24.0.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", () => { it("persists a supported Linux Node path before noninteractive shell guards", () => {
@@ -1477,8 +1503,8 @@ NODE
installedNode, installedNode,
[ [
"#!/usr/bin/env bash", "#!/usr/bin/env bash",
'if [[ "${1:-}" == "-p" ]]; then echo "24 13"; exit 0; fi', 'if [[ "${1:-}" == "-p" ]]; then echo "24 15"; exit 0; fi',
'if [[ "${1:-}" == "-v" ]]; then echo "v24.13.0"; exit 0; fi', 'if [[ "${1:-}" == "-v" ]]; then echo "v24.15.0"; exit 0; fi',
"", "",
].join("\n"), ].join("\n"),
); );
@@ -260,7 +260,15 @@ exit 1
it("rejects Node 22 wildcard matches below the supported minimum", () => { it("rejects Node 22 wildcard matches below the supported minimum", () => {
expect(runVersionMatch("22.18.0", "22.x").status).toBe(1); 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", () => { 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"); 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[] = []; const stderr: string[] = [];
try { try {
runInNewContext(extractNonrootNodePreflight(), { runInNewContext(extractNonrootNodePreflight(), {
@@ -78,7 +81,17 @@ function runNonrootNodePreflight(version: string, options: { sqlite?: boolean }
if (specifier === "node:sqlite" && options.sqlite === false) { if (specifier === "node:sqlite" && options.sqlite === false) {
throw new Error("missing node:sqlite"); 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("") }; 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", () => { 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.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", () => { 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.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", () => { 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("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", () => { it("runs the root Dockerfile build with the CI heap limit", () => {