Skip to content

Commit cccc856

Browse files
fix: reject incompatible Node 23 runtimes (#99832)
* fix: reject incompatible Node 23 runtimes * fix: repair installer CI coverage * docs: clarify supported Node ranges * fix: fail closed on unreadable runtime versions --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent a845c0e commit cccc856

21 files changed

Lines changed: 395 additions & 174 deletions

SECURITY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,15 +316,15 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
316316

317317
### Node.js Version
318318

319-
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
319+
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:
320320

321321
- CVE-2025-59466: async_hooks DoS vulnerability
322322
- CVE-2026-21636: Permission model bypass vulnerability
323323

324324
Verify your Node.js version:
325325

326326
```bash
327-
node --version # Should be v22.19.0 or later
327+
node --version # Should be v22.19+, v23.11+, or v24+
328328
```
329329

330330
### Docker Security

apps/macos/Sources/OpenClaw/RuntimeLocator.swift

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,26 @@ enum RuntimeResolutionError: Error {
4646
case unsupported(
4747
kind: RuntimeKind,
4848
found: RuntimeVersion,
49-
required: RuntimeVersion,
5049
path: String,
5150
searchPaths: [String])
5251
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
5352
}
5453

5554
enum RuntimeLocator {
5655
private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime")
57-
private static let minNode = RuntimeVersion(major: 22, minor: 19, patch: 0)
56+
private static let minNode22 = RuntimeVersion(major: 22, minor: 19, patch: 0)
57+
private static let minNode23 = RuntimeVersion(major: 23, minor: 11, patch: 0)
58+
private static let supportedNodeRange = ">=22.19.0 <23 or >=23.11.0"
59+
60+
static func isSupportedNodeVersion(_ version: RuntimeVersion) -> Bool {
61+
if version.major == self.minNode22.major {
62+
return version >= self.minNode22
63+
}
64+
if version.major == self.minNode23.major {
65+
return version >= self.minNode23
66+
}
67+
return version.major > self.minNode23.major
68+
}
5869

5970
static func resolve(
6071
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
@@ -75,11 +86,10 @@ enum RuntimeLocator {
7586
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
7687
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
7788
}
78-
guard parsed >= self.minNode else {
89+
guard self.isSupportedNodeVersion(parsed) else {
7990
return .failure(.unsupported(
8091
kind: runtime,
8192
found: parsed,
82-
required: self.minNode,
8393
path: binary,
8494
searchPaths: searchPaths))
8595
}
@@ -91,21 +101,21 @@ enum RuntimeLocator {
91101
switch error {
92102
case let .notFound(searchPaths):
93103
[
94-
"openclaw needs Node >=22.19.0 but found no runtime.",
104+
"openclaw needs Node \(self.supportedNodeRange) but found no runtime.",
95105
"PATH searched: \(searchPaths.joined(separator: ":"))",
96106
"Install Node: https://nodejs.org/en/download",
97107
].joined(separator: "\n")
98-
case let .unsupported(kind, found, required, path, searchPaths):
108+
case let .unsupported(kind, found, path, searchPaths):
99109
[
100-
"Found \(kind.rawValue) \(found) at \(path) but need >= \(required).",
110+
"Found \(kind.rawValue) \(found) at \(path) but need \(self.supportedNodeRange).",
101111
"PATH searched: \(searchPaths.joined(separator: ":"))",
102112
"Upgrade Node and rerun openclaw.",
103113
].joined(separator: "\n")
104114
case let .versionParse(kind, raw, path, searchPaths):
105115
[
106116
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
107117
"PATH searched: \(searchPaths.joined(separator: ":"))",
108-
"Try reinstalling or pinning a supported version (Node >=22.19.0).",
118+
"Try reinstalling or pinning a supported version (Node \(self.supportedNodeRange)).",
109119
].joined(separator: "\n")
110120
}
111121
}

apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,52 @@ struct RuntimeLocatorTests {
3535
"""
3636
let node = try self.makeTempExecutable(contents: script)
3737
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
38-
guard case let .failure(.unsupported(_, found, required, path, _)) = result else {
38+
guard case let .failure(.unsupported(_, found, path, _)) = result else {
3939
Issue.record("Expected unsupported error, got \(result)")
4040
return
4141
}
4242
#expect(found == RuntimeVersion(major: 22, minor: 18, patch: 9))
43-
#expect(required == RuntimeVersion(major: 22, minor: 19, patch: 0))
4443
#expect(path == node.path)
4544
}
4645

46+
@Test func `resolve rejects early node 23`() throws {
47+
let script = """
48+
#!/bin/sh
49+
echo v23.7.0
50+
"""
51+
let node = try self.makeTempExecutable(contents: script)
52+
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
53+
guard case let .failure(.unsupported(_, found, path, _)) = result else {
54+
Issue.record("Expected unsupported error, got \(result)")
55+
return
56+
}
57+
#expect(found == RuntimeVersion(major: 23, minor: 7, patch: 0))
58+
#expect(path == node.path)
59+
}
60+
61+
@Test func `resolve accepts node 23 with statement columns`() throws {
62+
let script = """
63+
#!/bin/sh
64+
echo v23.11.0
65+
"""
66+
let node = try self.makeTempExecutable(contents: script)
67+
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
68+
guard case let .success(res) = result else {
69+
Issue.record("Expected success, got \(result)")
70+
return
71+
}
72+
#expect(res.path == node.path)
73+
#expect(res.version == RuntimeVersion(major: 23, minor: 11, patch: 0))
74+
}
75+
4776
@Test func `resolve fails when too old`() throws {
4877
let script = """
4978
#!/bin/sh
5079
echo v18.2.0
5180
"""
5281
let node = try self.makeTempExecutable(contents: script)
5382
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
54-
guard case let .failure(.unsupported(_, found, _, path, _)) = result else {
83+
guard case let .failure(.unsupported(_, found, path, _)) = result else {
5584
Issue.record("Expected unsupported error, got \(result)")
5685
return
5786
}
@@ -76,7 +105,7 @@ struct RuntimeLocatorTests {
76105

77106
@Test func `describe failure includes paths`() {
78107
let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"]))
79-
#expect(msg.contains("Node >=22.19.0"))
108+
#expect(msg.contains("Node >=22.19.0 <23 or >=23.11.0"))
80109
#expect(msg.contains("PATH searched: /tmp/a:/tmp/b"))
81110

82111
let parseMsg = RuntimeLocator.describeFailure(
@@ -85,7 +114,7 @@ struct RuntimeLocatorTests {
85114
raw: "garbage",
86115
path: "/usr/local/bin/node",
87116
searchPaths: ["/usr/local/bin"]))
88-
#expect(parseMsg.contains("Node >=22.19.0"))
117+
#expect(parseMsg.contains("Node >=22.19.0 <23 or >=23.11.0"))
89118
}
90119

91120
@Test func `runtime version parses with leading V and metadata`() {

docs/install/node.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ read_when:
77
- "npm install -g fails with permissions or PATH issues"
88
---
99

10-
OpenClaw requires **Node 22.19 or newer**. **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) will detect and install Node automatically - this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
10+
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) will detect and install Node automatically - this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs).
1111

1212
## Check your version
1313

1414
```bash
1515
node -v
1616
```
1717

18-
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.19.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below.
18+
If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.19.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. Node 23 versions before `v23.11.0` are unsupported. If Node isn't installed or the version is outside the supported range, pick an install method below.
1919

2020
## Install Node
2121

docs/plugins/building-plugins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Bare package specs still install from npm during the launch cutover. Use the
2525

2626
## Requirements
2727

28-
- Use Node 22.19 or newer and a package manager such as `npm` or `pnpm`.
28+
- Use Node 22.19+, Node 23.11+, or Node 24+ and a package manager such as `npm` or `pnpm`.
2929
- Be familiar with TypeScript ESM modules.
3030
- For in-repo bundled plugin work, clone the repository and run `pnpm install`.
3131
Source-checkout plugin development is pnpm-only because OpenClaw loads bundled

npm-shrinkwrap.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openclaw.mjs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import { fileURLToPath } from "node:url";
1010

1111
const MIN_NODE_MAJOR = 22;
1212
const MIN_NODE_MINOR = 19;
13-
const MIN_NODE_VERSION = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`;
13+
const MIN_NODE_23_MINOR = 11;
14+
const RECOMMENDED_NODE_MAJOR = 24;
15+
const SUPPORTED_NODE_RANGE = ">=22.19.0 <23 or >=23.11.0";
1416
const MIN_COMPILE_CACHE_NODE_24_MINOR = 15;
1517
const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED";
1618

@@ -22,9 +24,15 @@ const parseNodeVersion = (rawVersion) => {
2224
};
2325
};
2426

25-
const isSupportedNodeVersion = (version) =>
26-
version.major > MIN_NODE_MAJOR ||
27-
(version.major === MIN_NODE_MAJOR && version.minor >= MIN_NODE_MINOR);
27+
const isSupportedNodeVersion = (version) => {
28+
if (version.major === MIN_NODE_MAJOR) {
29+
return version.minor >= MIN_NODE_MINOR;
30+
}
31+
if (version.major === 23) {
32+
return version.minor >= MIN_NODE_23_MINOR;
33+
}
34+
return version.major > 23;
35+
};
2836

2937
const isNodeVersionAffectedByCompileCacheDeadlock = (rawVersion) => {
3038
const version = parseNodeVersion(rawVersion);
@@ -41,11 +49,11 @@ const ensureSupportedNodeVersion = () => {
4149
}
4250

4351
process.stderr.write(
44-
`openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
52+
`openclaw: Node.js ${SUPPORTED_NODE_RANGE} is required (current: v${process.versions.node}).\n` +
4553
"If you use nvm, run:\n" +
46-
` nvm install ${MIN_NODE_MAJOR}\n` +
47-
` nvm use ${MIN_NODE_MAJOR}\n` +
48-
` nvm alias default ${MIN_NODE_MAJOR}\n`,
54+
` nvm install ${RECOMMENDED_NODE_MAJOR}\n` +
55+
` nvm use ${RECOMMENDED_NODE_MAJOR}\n` +
56+
` nvm alias default ${RECOMMENDED_NODE_MAJOR}\n`,
4957
);
5058
process.exit(1);
5159
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2060,7 +2060,7 @@
20602060
"sqlite-vec": "0.1.9"
20612061
},
20622062
"engines": {
2063-
"node": ">=22.19.0"
2063+
"node": ">=22.19.0 <23 || >=23.11.0"
20642064
},
20652065
"packageManager": "[email protected]+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f"
20662066
}

scripts/install-cli.sh

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ if [[ -n "${OPENCLAW_NODE_VERSION:-}" ]]; then
5858
NODE_VERSION_REQUESTED=1
5959
fi
6060
MIN_NODE_VERSION="22.19.0"
61+
MIN_NODE_23_VERSION="23.11.0"
62+
SUPPORTED_NODE_VERSION_LABEL="Node 22.19+, Node 23.11+, or Node 24+"
6163
APK_NODE_BIN_DIR="/usr/bin"
6264
NPM_LOGLEVEL="${OPENCLAW_NPM_LOGLEVEL:-error}"
6365
INSTALL_METHOD="${OPENCLAW_INSTALL_METHOD:-npm}"
@@ -423,11 +425,14 @@ linked_node_is_usable() {
423425

424426
current_version="$("$(node_bin)" -v 2>/dev/null || echo "")"
425427
required_version="$(required_node_version)"
428+
if ! node_version_is_supported "$current_version"; then
429+
return 1
430+
fi
426431
if ! semver_at_least "$current_version" "$required_version"; then
427432
return 1
428433
fi
429434

430-
"$(node_bin)" -e "require('node:sqlite')" >/dev/null 2>&1
435+
"$(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
431436
}
432437

433438
semver_at_least() {
@@ -460,8 +465,32 @@ semver_at_least() {
460465
((version_patch >= required_patch))
461466
}
462467

468+
node_version_is_supported() {
469+
local version="${1#v}"
470+
local major minor patch
471+
472+
IFS=. read -r major minor patch <<<"$version"
473+
minor="${minor:-0}"
474+
patch="${patch:-0}"
475+
for part in "$major" "$minor" "$patch"; do
476+
if [[ ! "$part" =~ ^[0-9]+$ ]]; then
477+
return 1
478+
fi
479+
done
480+
481+
if ((major == 22)); then
482+
semver_at_least "$version" "$MIN_NODE_VERSION"
483+
return
484+
fi
485+
if ((major == 23)); then
486+
semver_at_least "$version" "$MIN_NODE_23_VERSION"
487+
return
488+
fi
489+
((major > 23))
490+
}
491+
463492
required_node_version() {
464-
if [[ "$NODE_VERSION_REQUESTED" == "1" ]] && semver_at_least "$NODE_VERSION" "$MIN_NODE_VERSION"; then
493+
if [[ "$NODE_VERSION_REQUESTED" == "1" ]] && node_version_is_supported "$NODE_VERSION"; then
465494
printf '%s\n' "$NODE_VERSION"
466495
return
467496
fi
@@ -767,6 +796,10 @@ install_node() {
767796
local expected_sha
768797
local actual_sha
769798

799+
if ! node_version_is_supported "$NODE_VERSION"; then
800+
fail "Node ${NODE_VERSION} is unsupported; use ${SUPPORTED_NODE_VERSION_LABEL}."
801+
fi
802+
770803
os="$(os_detect)"
771804
arch="$(arch_detect)"
772805
dir="$(node_dir)"

scripts/install.ps1

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,18 +149,33 @@ if ([string]::IsNullOrWhiteSpace($GitDir)) {
149149
}
150150

151151
# Check for Node.js
152+
function Test-NodeVersionSupported {
153+
param([string]$Version)
154+
155+
$versionMatch = [regex]::Match($Version, '^v?(?<major>\d+)\.(?<minor>\d+)\.')
156+
if (-not $versionMatch.Success) {
157+
return $false
158+
}
159+
$major = [int]$versionMatch.Groups["major"].Value
160+
$minor = [int]$versionMatch.Groups["minor"].Value
161+
if ($major -eq 22) {
162+
return ($minor -ge 19)
163+
}
164+
if ($major -eq 23) {
165+
return ($minor -ge 11)
166+
}
167+
return ($major -gt 23)
168+
}
169+
152170
function Check-Node {
153171
try {
154172
$nodeVersion = (node -v 2>$null)
155173
if ($nodeVersion) {
156-
$versionMatch = [regex]::Match($nodeVersion, '^v(?<major>\d+)\.(?<minor>\d+)\.')
157-
$major = if ($versionMatch.Success) { [int]$versionMatch.Groups["major"].Value } else { 0 }
158-
$minor = if ($versionMatch.Success) { [int]$versionMatch.Groups["minor"].Value } else { 0 }
159-
if (($major -gt 22) -or (($major -eq 22) -and ($minor -ge 19))) {
174+
if (Test-NodeVersionSupported -Version $nodeVersion) {
160175
Write-Host "[OK] Node.js $nodeVersion found" -ForegroundColor Green
161176
return $true
162177
} else {
163-
Write-Host "[!] Node.js $nodeVersion found, but v22.19+ required" -ForegroundColor Yellow
178+
Write-Host "[!] Node.js $nodeVersion found, but Node 22.19+, Node 23.11+, or Node 24+ is required" -ForegroundColor Yellow
164179
return $false
165180
}
166181
}

0 commit comments

Comments
 (0)