Problem
Every Node.js pin in this repo targets Node 20, which reached end-of-life on 2026-04-30 — three months ago. Node 20 no longer receives security patches of any kind. The pin is present in 11 CI jobs, the local bootstrap script, the installer, the EC2 cloud template, and (transitively, via Electron 33) the runtime shipped inside the desktop app.
Separately, the repo declares six mutually incompatible Node floors (16, 18, 20, 22, 24, 25), four of which are EOL. kirocrew doctor currently reports a green checkmark for a Node version that kirocrew chat refuses to start on.
Evidence — every Node declaration on main (2108dec)
CI (hard pins)
| File:line |
Value |
.github/workflows/ci.yml:397 |
node-version: 20 |
.github/workflows/ci.yml:532 |
node-version: 20 |
.github/workflows/ci.yml:570 |
node-version: 20 |
.github/workflows/build.yml:30 |
node-version: 20 |
.github/workflows/build.yml:74 |
node-version: 20 |
.github/workflows/build-desktop.yml:119 |
node-version: 20 |
.github/workflows/build-wheel.yml:36 |
node-version: 20 |
.github/workflows/docker-smoke.yml:59 |
node-version: 20 |
.github/workflows/docker-smoke.yml:191 |
node-version: 20 |
.github/workflows/pages.yml:34 |
node-version: 20 |
.github/workflows/dependency-vulnerability.yml:24 |
node-version: "20.19.4" (exact patch) |
.github/workflows/ota-test.yml:68 |
node-version: "22" — the only outlier, and load-bearing (see below) |
Bootstrap, install, deploy
| File:line |
Value |
ensure-node.sh:13 |
MIN_VERSION=18 |
ensure-node.sh:14 |
TARGET_VERSION=20 |
ensure-node.sh:22 |
AL2_NODE_VERSION=16 (also applied at :90-91, :142-143) |
install.sh:44 |
NODE_VERSION="20" |
src/kiro_crew/cloud/templates/kirocrew-ec2.yaml:380 |
NodeSource pub_20.x repo |
website/package.json:10 |
"engines": { "node": "20 || >=22" } |
website/electron/package.json:19 |
"electron": "^33.0.0" → embeds Node 20.18.0 |
There is no .nvmrc, .node-version, .tool-values, mise.toml, or volta field anywhere, so nothing pins Node for local development and no .npmrc sets engine-strict, making the engines field advisory only.
Python-side gates (contradictory)
| File:line |
Value |
src/kiro_crew/cli.py:262 |
_MIN_NODE_VERSION = 16 |
src/kiro_crew/cli_doctor.py:59 |
_MIN_NODE_VERSION = 16 (duplicated constant) |
src/kiro_crew/cli_chat.py:67 |
process.exit(... < 20 ? 1 : 0) — hard sys.exit(1) below 20 |
src/kiro_crew/cli_chat.py:73 |
"Node.js >= 20 required. Current version is too old." |
Documentation floors (four different answers)
| File:line |
Claim |
docs/getting-started.md:10 |
Node.js 16+ |
docs/app-kit/getting-started.md:8 |
Node.js 18+ |
src/kiro_crew/docs/getting-started.md:13 |
Node.js 18+ |
CONTRIBUTING.md:10 |
Node.js ≥ 18 |
docs/remote-desktop-setup.md:10,23,35 |
Node 20+ |
docs/WINDOWS_CHANGES.md:9 |
Node 24 (tested config) |
Support status (primary source)
From nodejs/Release/schedule.json, evaluated 2026-08-01:
| Major |
EOL |
Status today |
| 16 (Gallium) |
2023-09-11 |
EOL — 2y 11m ago |
| 18 (Hydrogen) |
2025-04-30 |
EOL — 15m ago |
| 20 (Iron) |
2026-04-30 |
EOL — 3m ago |
| 22 (Jod) |
2027-04-30 |
Maintenance LTS (since 2025-10-21) |
| 24 (Krypton) |
2028-04-30 |
Active LTS until 2026-10-20 |
| 25 |
2026-06-01 |
EOL (odd-numbered) |
| 26 |
2029-04-30 |
Current; becomes LTS 2026-10-28 |
Why it matters
- No security patches. CI, release artifacts, and the desktop runtime all execute an unpatched Node. Ironically
dependency-vulnerability.yml:24 — the vulnerability scan itself — runs on the EOL runtime.
- The desktop app is the worst case.
^33.0.0 cannot resolve past Node 20.x (stable Electron lines only take minor/patch Node bumps), so the >=22 branch of the engines field is unreachable for the Electron process. Electron's support policy is the latest three stable majors; Electron 33 (Chromium 130) is roughly ten majors behind, so it also receives no Chromium security fixes. This is a shipped-to-users concern, not just a CI one.
doctor and chat disagree. cli_doctor.py:59 prints ✅ for any major ≥ 16; cli_chat.py:67 exits 1 below 20. A user on Node 18 gets a clean doctor report and an immediate launch failure.
ensure-node.sh cannot satisfy the engines field it exists to serve. It accepts an existing Node ≥ 18 (:39) and returns without installing, while website/package.json:10 requires 20 || >=22.
- It is already blocking other work. Two places are held back by the old floor, not by their own constraints:
.vulnerability-exceptions.json:8,16 — two live accepted vulnerabilities whose stated removal condition is the Node 22 migration: "the 8.3.0 fix requires Node 22 and React 19. Remove this exception during that migration."
src/kiro_crew/apps/builtins/design_critique/.../scripts/ensure-playwright.mjs:23 — PW_VERSION = '1.47.2' // last line that supports Node 18. A shipped runtime path is pinned to a two-year-old Playwright purely to stay compatible with a Node line that is now 15 months dead.
- The ecosystem is already moving. Several installed dependencies now express ranges of the shape
20 || >=22 (glob@13, minimatch@10, test-exclude@8) or ^20.17.0 || >=22.9.0 (mute-stream@3) — Node 20 is in their legacy branch. The next routine dependency bump is what turns this from latent into blocking.
Why the Amazon Linux 2 escape hatch is no longer a blocker
#49 added a deliberate Node 16 path for AL2 because AL2 ships glibc 2.26 while official Node ≥ 18 binaries are linked against glibc ≥ 2.28. That constraint is real and unchanged — nodejs/node/BUILDING.md lists Tier 1 GNU/Linux as glibc >= 2.28. But it does not block this upgrade, for two reasons:
- AL2 is already on a separate branch of
ensure-node.sh (:90-91). Moving TARGET_VERSION from 20 to 24 leaves the AL2 path untouched, so this bump cannot make AL2 worse.
- AL2 itself reached end of support on 2026-06-30 (AWS AL2 FAQ: "Amazon Linux 2 reached its end of support on 2026-06-30... is no longer receiving standard security updates"). AL2023 ships glibc 2.34, clearing the Node 2.28 floor comfortably.
So the AL2 fallback should be reframed as a deprecated compatibility path, not a constraint on the primary target.
Correction to a claim I made earlier in this issue: AL2 is not necessarily stuck on Node 16. nodejs/unofficial-builds publishes a glibc-2.17 x64 build, and its authoritative gate script — recipes/x64-glibc-217/should-build.sh — carries isNodeVersionLT 'v27.0' with the comment "v24 and v26 verified on glibc 2.17". Verified live: node-v24.18.1-linux-x64-glibc-217.tar.{gz,xz} exists at unofficial-builds.nodejs.org/download/release/v24.18.1/. (The project's README still claims glibc-217 stops at v23 — the README is stale, the gate script is authoritative.)
Two caveats before anyone relies on it: the project self-describes as experimental with "minimal or no testing" and no guarantees, and there is no arm64 glibc-217 recipe — the recipe list is arm64-musl, armv6l, centos7-toolchain, headers, loong64, musl, riscv64*, x64-debug, x64-glibc-217, x64-pointer-compression. Since ensure-node.sh:18-19 specifically notes Node 16 "runs on AL2 for both x86_64 and aarch64", an AL2 aarch64 host has no glibc-217 path at all — only musl. So this is a viable escape hatch for AL2 x64 only, and it does not change the recommendation for the primary target.
Also worth stating, because it is a plausible objection: Node 24 does not raise the glibc floor. The Tier 1 row is byte-identical (glibc >= 2.28) across the v20.x, v22.x, v24.x and main BUILDING.md files. Node 24's semver-major platform commits raised only the toolchain floor — macOS 13.5, Xcode 16.1 — per the v24.0.0 release notes. The one real per-major platform regression is armv7: Tier 1 with an official linux-armv7l binary on 20 and 22, downgraded to Experimental on 24 with linux-armv7l removed from the official binary table. arm64 stays Tier 1, so docker-smoke.yml:191's arm64 cross-build is unaffected — but anyone targeting 32-bit ARM should confirm before this lands.
Upgrade risk assessment (measured, not assumed)
Scanned website/node_modules (841 installed packages) and grepped the source tree:
- No dependency has a hard floor above 20 — but the real floor is 20.19, not 20. The highest constraint in the repo is
eslint-visitor-keys (nested under @typescript-eslint/visitor-keys) at ^20.19.0 || ^22.13.0 || >=24, with the five @inquirer/* packages close behind at >=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0. Bare node-version: 20 resolves to the newest 20.x (20.20.2) so this is satisfied today, and dependency-vulnerability.yml:24's exact 20.19.4 clears ^20.19.0 by four patch releases — but only just. The actionable consequence: if the target were 22, the floor is 22.13.0, not 22. Node 24 is unconstrained by every range in the tree.
- No native-addon ABI risk, across all three projects. In
website/ only two packages have install scripts — esbuild@0.21.5 (downloads a prebuilt binary) and msw@2.12.14. site/ has zero install/postinstall scripts despite carrying 25 @tailwindcss/oxide* entries (4.3.2, engines.node: ">= 20"): those are prebuilt per-platform Rust/napi binaries delivered as optionalDependencies, the same model as esbuild and rollup, and N-API is ABI-stable across Node majors. No node-gyp anywhere. Electron's ABI is separate and handled by electron-builder.
- Worth stating explicitly because it is easy to miss:
site/ is a third project with its own Node exposure, built by pages.yml:34 on Node 20 and path-scoped to site/**. It has no engines field, no @types/node constraint, and its own lockfile — so it needs to be in scope for every step below, not just website/.
- Zero hits for the classic Node 22/24 removals across
website/src, website/scripts, website/electron, site/src, src/kiro_crew, and all *.mjs: punycode, url.parse, util._extend, createCipher/createDecipher, fs.rmdir recursive, process.binding, new Buffer(, tls.createSecurePair, and assert { type: 'json' } import attributes.
- Toolchain headroom is fine:
vite@5.3.6 (^18 || >=20), vitest@3.2.4 (^18 || ^20 || >=22), @playwright/test@1.58.2 (>=18), happy-dom@20 (>=20).
- tsconfig is orthogonal:
types is absent from all four tsconfigs, moduleResolution is "bundler" (models the bundler, not Node), and noEmit: true — so no tsconfig edit is required.
But three things DO break on the way up
The scan above answers "does anything block 22/24"; it does not answer "does anything assume 20". Three sites assert the absence of post-20 APIs and fail when the runtime moves up:
| Site |
Assumption |
website/src/i18n/format.test.ts:147 |
asserts Intl.DurationFormat is undefined on the Node 20 baseline |
website/src/i18n/englishIdentity.test.ts:124-140 |
avoids fs.globSync with a hand-rolled walk because it is undefined on Node 20 |
website/integration/setup.ts:76-77 |
localStorage polyfill branches on Node 22+ shipping a native one, and differs again on 25 |
Plus one CI-gate coupling: test/test_dependency_vulnerability_gate.py:133 asserts node-version == "20.19.4" verbatim, so editing dependency-vulnerability.yml:24 alone turns the build red.
And website/electron/package.json:8 runs node --test test/*.test.js on the host Node (not Electron's bundled one). The built-in test runner's semantics — default concurrency, per-file isolation, glob handling, reporter output — changed materially across 20 → 22 → 24. This is the one genuinely version-sensitive script in the repo and needs to be run, not reasoned about.
ota-test.yml's Node 22 is a real floor, not drift
website/electron/scripts/ota-drive.js:73 constructs a bare global WebSocket — there is no ws dependency in website/electron/package.json, so it must come from the runtime. Per Node's globals docs the global was added in v20.10.0 behind --experimental-websocket and only unflagged in v22.0.0; ota-test.yml:150 invokes the script bare. On Node 20 it dies with ReferenceError: WebSocket is not defined. It is unavoidable by design — CDP is a WebSocket protocol. The pin is correct and must never be lowered; it is also completely undocumented (a 35-line header comment that explains everything else, and one dependabot commit that only touched the action version).
The @types/node situation is worse than one stale pin
Three lockfiles, three different majors, three different causes:
| Lockfile |
Resolved |
Cause |
website/package-lock.json:3964 |
25.5.0 |
the overrides pin at website/package.json:126 |
site/package-lock.json:1900 |
26.1.1 |
no constraint at all — every requester asks "*", so it floats |
website/electron/package-lock.json:741 |
20.19.37 |
electron@33.4.11 hard-depends on @types/node: "^20.9.0" |
So site/ typechecks against Node 26 types on a Node 20 runtime, and website/ against Node 25 types — and Node 25 is itself EOL (2026-06-01). This is invisible because skipLibCheck: true is set in all three real projects and no tsconfig declares types, which converts a class of build-time errors into runtime errors. DefinitelyTyped no longer ships a types/node/v20 folder at all, so Node 20 types are unmaintained upstream. .github/dependabot.yml has only a github-actions entry and no npm ecosystem, so nothing will ever propose a fix.
vite@5.3.6 is the weakest link, independently of this bump
Pinned exact at website/package.json:110. Per vite's own release policy only the latest minor, previous major's latest minor, and previous minor are supported; with latest at 8.x that puts 5.3.6 two majors out of support — no fixes, no security patches. Its engines (^18.0.0 || >=20.0.0) nominally admits 24, but vite only started CI-testing on Node 24 in vite 7.0.0, and the bundled esbuild@0.21.5 (2024-06-09) predates Node 24's release by ~11 months and was never tested against it upstream. Note vitest 3.2.4 caps its vite dep at ^7, so vite 7 is reachable without touching vitest; vite 8 would force vitest and @vitest/coverage-v8 to move too.
Verdict: target Node 24 (Active LTS, EOL 2028-04-30). Node 22 is already in maintenance with only ~9 months of headroom. Node 26 is premature (LTS on 2026-10-28) and from Node 25 onward official Linux binaries additionally require the libatomic runtime at execution time (per BUILDING.md), which adds a deployment prerequisite.
Proposed fix
Sequenced so each step is independently reviewable:
- Pin the toolchain. Add
.nvmrc (24) and a mise.toml entry so local dev and CI agree; set engines.node to >=22 in website/package.json (allow 22 for contributors, target 24 in CI).
- Bump CI. Replace
node-version: 20 with 24 in the 10 floating sites; update dependency-vulnerability.yml:24 to a 24.x patch and test/test_dependency_vulnerability_gate.py:133 in the same commit; leave ota-test.yml:68's 22 alone or raise it to 24, never lower; separately, align ota-test.yml:66 onto the same SHA-pinned actions/setup-node the other 11 jobs use (it is the only floating tag in the repo).
- Unify the floors. Raise
TARGET_VERSION to 24 in ensure-node.sh:14 and NODE_VERSION in install.sh:44; move MIN_VERSION to 22; switch kirocrew-ec2.yaml:380 off the pub_20.x NodeSource repo. Note that install.sh:246-251 (apt-get install -y nodejs npm / dnf install -y nodejs) and install.sh:254 (brew install node) ignore NODE_VERSION entirely and then only report what landed — Debian/Ubuntu can install a Node far below any declared floor. Those paths need a post-install version assertion, not just a new constant.
- Collapse the Python constants. Single shared
_MIN_NODE_VERSION (22) imported by cli.py, cli_doctor.py, and cli_chat.py so doctor and chat can never disagree again; update the six user-facing message strings.
- Reconcile
@types/node. Re-pin website/package.json:126 from 25.5.0 to 24.x so types match the runtime 1:1; give site/ an explicit pin so it stops floating (it is at 26.1.1 today); add an npm ecosystem entry to .github/dependabot.yml so this cannot silently diverge again.
- Fix the docs — one floor, stated once. The list is larger than the six above: also
Makefile:33, minimal_install.sh:14,55,56, setup.sh:65, install.sh:339, docs/windows-install.md:35, docs/system-specs/modules/security.md:194 (hardcodes "CI pins Node 20.19.4"), docs/system-specs/modules/acp-client.md:19, and site/src/SectionsBottom.tsx:74 — the public marketing page says "Node.js 18+".
- Add a regression gate so this cannot silently rot again: a test asserting every
node-version: in .github/workflows/** matches .nvmrc, plus a check that the pinned major is not past its EOL date in nodejs/Release/schedule.json.
- Electron upgrade — separate issue. Moving
^33.0.0 onto a supported line (latest three majors) is a materially larger change (Chromium/V8 breaking changes, electron-builder@25 may also need bumping, notarization and OTA update paths need re-testing). Tracking it here only as a dependency, not doing it in the same PR.
Out of scope
docker/Dockerfile contains no Node at all (FROM python:3.12-slim-trixie), yet frontend.py:202,261 and apps/backend.py:330,338 call shutil.which("npm")/which("node") at runtime. That is a separate gap worth its own issue.
- Unpinned indirect Node consumers, invisible to any
node-version grep: codex-review.yml:98 runs npm install -g @openai/codex with no setup-node step at all, so the GPT review gate executes on whatever Node the runner ships; four anthropics/claude-code-action call sites (claude-review.yml:132, design-review.yml:69, longterm-arbiter.yml:244, ux-review.yml:94) likewise. Separately, code-review.yml:218, nightly.yml:110, and release.yml:70 inherit pinned Node through reusable-workflow calls — grepping those three files reports "no Node" and is wrong.
- Nine call sites shell out to
node/npm/npx with a presence check but no version check (record_browser.py:51, apps/registry.py:1751, mcp_playwright_proxy.py:640, scripts/check_npm_audit.py:173, and others). Worth its own issue because the failure mode is not just unversioned, it is silent: apps/backend.py:767-774 logs and returns None when no node is found, and routes.py:952 still answers the enable request with a success-shaped HTTP 200 — the problem only surfaces later as a generic 502 "app '<name>' has no reachable backend" that names neither node nor the search order. The design-critique app degrades the same way, into a user-facing "couldn't see this screen" list rather than a diagnosable error.
Problem
Every Node.js pin in this repo targets Node 20, which reached end-of-life on 2026-04-30 — three months ago. Node 20 no longer receives security patches of any kind. The pin is present in 11 CI jobs, the local bootstrap script, the installer, the EC2 cloud template, and (transitively, via Electron 33) the runtime shipped inside the desktop app.
Separately, the repo declares six mutually incompatible Node floors (16, 18, 20, 22, 24, 25), four of which are EOL.
kirocrew doctorcurrently reports a green checkmark for a Node version thatkirocrew chatrefuses to start on.Evidence — every Node declaration on
main(2108dec)CI (hard pins)
.github/workflows/ci.yml:397node-version: 20.github/workflows/ci.yml:532node-version: 20.github/workflows/ci.yml:570node-version: 20.github/workflows/build.yml:30node-version: 20.github/workflows/build.yml:74node-version: 20.github/workflows/build-desktop.yml:119node-version: 20.github/workflows/build-wheel.yml:36node-version: 20.github/workflows/docker-smoke.yml:59node-version: 20.github/workflows/docker-smoke.yml:191node-version: 20.github/workflows/pages.yml:34node-version: 20.github/workflows/dependency-vulnerability.yml:24node-version: "20.19.4"(exact patch).github/workflows/ota-test.yml:68node-version: "22"— the only outlier, and load-bearing (see below)Bootstrap, install, deploy
ensure-node.sh:13MIN_VERSION=18ensure-node.sh:14TARGET_VERSION=20ensure-node.sh:22AL2_NODE_VERSION=16(also applied at:90-91,:142-143)install.sh:44NODE_VERSION="20"src/kiro_crew/cloud/templates/kirocrew-ec2.yaml:380pub_20.xrepowebsite/package.json:10"engines": { "node": "20 || >=22" }website/electron/package.json:19"electron": "^33.0.0"→ embeds Node 20.18.0There is no
.nvmrc,.node-version,.tool-values,mise.toml, orvoltafield anywhere, so nothing pins Node for local development and no.npmrcsetsengine-strict, making theenginesfield advisory only.Python-side gates (contradictory)
src/kiro_crew/cli.py:262_MIN_NODE_VERSION = 16src/kiro_crew/cli_doctor.py:59_MIN_NODE_VERSION = 16(duplicated constant)src/kiro_crew/cli_chat.py:67process.exit(... < 20 ? 1 : 0)— hardsys.exit(1)below 20src/kiro_crew/cli_chat.py:73"Node.js >= 20 required. Current version is too old."Documentation floors (four different answers)
docs/getting-started.md:10Node.js 16+docs/app-kit/getting-started.md:8Node.js 18+src/kiro_crew/docs/getting-started.md:13Node.js 18+CONTRIBUTING.md:10Node.js ≥ 18docs/remote-desktop-setup.md:10,23,35Node 20+docs/WINDOWS_CHANGES.md:9Node 24(tested config)Support status (primary source)
From
nodejs/Release/schedule.json, evaluated 2026-08-01:Why it matters
dependency-vulnerability.yml:24— the vulnerability scan itself — runs on the EOL runtime.^33.0.0cannot resolve past Node 20.x (stable Electron lines only take minor/patch Node bumps), so the>=22branch of theenginesfield is unreachable for the Electron process. Electron's support policy is the latest three stable majors; Electron 33 (Chromium 130) is roughly ten majors behind, so it also receives no Chromium security fixes. This is a shipped-to-users concern, not just a CI one.doctorandchatdisagree.cli_doctor.py:59prints ✅ for any major ≥ 16;cli_chat.py:67exits 1 below 20. A user on Node 18 gets a clean doctor report and an immediate launch failure.ensure-node.shcannot satisfy theenginesfield it exists to serve. It accepts an existing Node ≥ 18 (:39) and returns without installing, whilewebsite/package.json:10requires20 || >=22..vulnerability-exceptions.json:8,16— two live accepted vulnerabilities whose stated removal condition is the Node 22 migration: "the 8.3.0 fix requires Node 22 and React 19. Remove this exception during that migration."src/kiro_crew/apps/builtins/design_critique/.../scripts/ensure-playwright.mjs:23—PW_VERSION = '1.47.2' // last line that supports Node 18. A shipped runtime path is pinned to a two-year-old Playwright purely to stay compatible with a Node line that is now 15 months dead.20 || >=22(glob@13,minimatch@10,test-exclude@8) or^20.17.0 || >=22.9.0(mute-stream@3) — Node 20 is in their legacy branch. The next routine dependency bump is what turns this from latent into blocking.Why the Amazon Linux 2 escape hatch is no longer a blocker
#49 added a deliberate Node 16 path for AL2 because AL2 ships glibc 2.26 while official Node ≥ 18 binaries are linked against glibc ≥ 2.28. That constraint is real and unchanged —
nodejs/node/BUILDING.mdlists Tier 1 GNU/Linux asglibc >= 2.28. But it does not block this upgrade, for two reasons:ensure-node.sh(:90-91). MovingTARGET_VERSIONfrom 20 to 24 leaves the AL2 path untouched, so this bump cannot make AL2 worse.So the AL2 fallback should be reframed as a deprecated compatibility path, not a constraint on the primary target.
Correction to a claim I made earlier in this issue: AL2 is not necessarily stuck on Node 16. nodejs/unofficial-builds publishes a glibc-2.17 x64 build, and its authoritative gate script —
recipes/x64-glibc-217/should-build.sh— carriesisNodeVersionLT 'v27.0'with the comment "v24 and v26 verified on glibc 2.17". Verified live:node-v24.18.1-linux-x64-glibc-217.tar.{gz,xz}exists at unofficial-builds.nodejs.org/download/release/v24.18.1/. (The project's README still claims glibc-217 stops at v23 — the README is stale, the gate script is authoritative.)Two caveats before anyone relies on it: the project self-describes as experimental with "minimal or no testing" and no guarantees, and there is no arm64 glibc-217 recipe — the recipe list is
arm64-musl, armv6l, centos7-toolchain, headers, loong64, musl, riscv64*, x64-debug, x64-glibc-217, x64-pointer-compression. Sinceensure-node.sh:18-19specifically notes Node 16 "runs on AL2 for both x86_64 and aarch64", an AL2 aarch64 host has no glibc-217 path at all — only musl. So this is a viable escape hatch for AL2 x64 only, and it does not change the recommendation for the primary target.Also worth stating, because it is a plausible objection: Node 24 does not raise the glibc floor. The Tier 1 row is byte-identical (
glibc >= 2.28) across the v20.x, v22.x, v24.x and mainBUILDING.mdfiles. Node 24's semver-major platform commits raised only the toolchain floor — macOS 13.5, Xcode 16.1 — per the v24.0.0 release notes. The one real per-major platform regression is armv7: Tier 1 with an officiallinux-armv7lbinary on 20 and 22, downgraded to Experimental on 24 withlinux-armv7lremoved from the official binary table. arm64 stays Tier 1, sodocker-smoke.yml:191's arm64 cross-build is unaffected — but anyone targeting 32-bit ARM should confirm before this lands.Upgrade risk assessment (measured, not assumed)
Scanned
website/node_modules(841 installed packages) and grepped the source tree:eslint-visitor-keys(nested under@typescript-eslint/visitor-keys) at^20.19.0 || ^22.13.0 || >=24, with the five@inquirer/*packages close behind at>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0. Barenode-version: 20resolves to the newest 20.x (20.20.2) so this is satisfied today, anddependency-vulnerability.yml:24's exact20.19.4clears^20.19.0by four patch releases — but only just. The actionable consequence: if the target were 22, the floor is 22.13.0, not22. Node 24 is unconstrained by every range in the tree.website/only two packages have install scripts —esbuild@0.21.5(downloads a prebuilt binary) andmsw@2.12.14.site/has zero install/postinstall scripts despite carrying 25@tailwindcss/oxide*entries (4.3.2,engines.node: ">= 20"): those are prebuilt per-platform Rust/napi binaries delivered as optionalDependencies, the same model as esbuild and rollup, and N-API is ABI-stable across Node majors. Nonode-gypanywhere. Electron's ABI is separate and handled byelectron-builder.site/is a third project with its own Node exposure, built bypages.yml:34on Node 20 and path-scoped tosite/**. It has noenginesfield, no@types/nodeconstraint, and its own lockfile — so it needs to be in scope for every step below, not justwebsite/.website/src,website/scripts,website/electron,site/src,src/kiro_crew, and all*.mjs:punycode,url.parse,util._extend,createCipher/createDecipher,fs.rmdirrecursive,process.binding,new Buffer(,tls.createSecurePair, andassert { type: 'json' }import attributes.vite@5.3.6(^18 || >=20),vitest@3.2.4(^18 || ^20 || >=22),@playwright/test@1.58.2(>=18),happy-dom@20(>=20).typesis absent from all four tsconfigs,moduleResolutionis"bundler"(models the bundler, not Node), andnoEmit: true— so no tsconfig edit is required.But three things DO break on the way up
The scan above answers "does anything block 22/24"; it does not answer "does anything assume 20". Three sites assert the absence of post-20 APIs and fail when the runtime moves up:
website/src/i18n/format.test.ts:147Intl.DurationFormatis undefined on the Node 20 baselinewebsite/src/i18n/englishIdentity.test.ts:124-140fs.globSyncwith a hand-rolled walk because it is undefined on Node 20website/integration/setup.ts:76-77localStoragepolyfill branches on Node 22+ shipping a native one, and differs again on 25Plus one CI-gate coupling:
test/test_dependency_vulnerability_gate.py:133assertsnode-version == "20.19.4"verbatim, so editingdependency-vulnerability.yml:24alone turns the build red.And
website/electron/package.json:8runsnode --test test/*.test.json the host Node (not Electron's bundled one). The built-in test runner's semantics — default concurrency, per-file isolation, glob handling, reporter output — changed materially across 20 → 22 → 24. This is the one genuinely version-sensitive script in the repo and needs to be run, not reasoned about.ota-test.yml's Node 22 is a real floor, not driftwebsite/electron/scripts/ota-drive.js:73constructs a bare globalWebSocket— there is nowsdependency inwebsite/electron/package.json, so it must come from the runtime. Per Node'sglobalsdocs the global was added in v20.10.0 behind--experimental-websocketand only unflagged in v22.0.0;ota-test.yml:150invokes the script bare. On Node 20 it dies withReferenceError: WebSocket is not defined. It is unavoidable by design — CDP is a WebSocket protocol. The pin is correct and must never be lowered; it is also completely undocumented (a 35-line header comment that explains everything else, and one dependabot commit that only touched the action version).The
@types/nodesituation is worse than one stale pinThree lockfiles, three different majors, three different causes:
website/package-lock.json:3964overridespin atwebsite/package.json:126site/package-lock.json:1900"*", so it floatswebsite/electron/package-lock.json:741electron@33.4.11hard-depends on@types/node: "^20.9.0"So
site/typechecks against Node 26 types on a Node 20 runtime, andwebsite/against Node 25 types — and Node 25 is itself EOL (2026-06-01). This is invisible becauseskipLibCheck: trueis set in all three real projects and no tsconfig declarestypes, which converts a class of build-time errors into runtime errors. DefinitelyTyped no longer ships atypes/node/v20folder at all, so Node 20 types are unmaintained upstream..github/dependabot.ymlhas only agithub-actionsentry and nonpmecosystem, so nothing will ever propose a fix.vite@5.3.6is the weakest link, independently of this bumpPinned exact at
website/package.json:110. Per vite's own release policy only the latest minor, previous major's latest minor, and previous minor are supported; with latest at 8.x that puts 5.3.6 two majors out of support — no fixes, no security patches. Itsengines(^18.0.0 || >=20.0.0) nominally admits 24, but vite only started CI-testing on Node 24 in vite 7.0.0, and the bundledesbuild@0.21.5(2024-06-09) predates Node 24's release by ~11 months and was never tested against it upstream. Note vitest 3.2.4 caps its vite dep at^7, so vite 7 is reachable without touching vitest; vite 8 would forcevitestand@vitest/coverage-v8to move too.Verdict: target Node 24 (Active LTS, EOL 2028-04-30). Node 22 is already in maintenance with only ~9 months of headroom. Node 26 is premature (LTS on 2026-10-28) and from Node 25 onward official Linux binaries additionally require the
libatomicruntime at execution time (perBUILDING.md), which adds a deployment prerequisite.Proposed fix
Sequenced so each step is independently reviewable:
.nvmrc(24) and amise.tomlentry so local dev and CI agree; setengines.nodeto>=22inwebsite/package.json(allow 22 for contributors, target 24 in CI).node-version: 20with24in the 10 floating sites; updatedependency-vulnerability.yml:24to a 24.x patch andtest/test_dependency_vulnerability_gate.py:133in the same commit; leaveota-test.yml:68's22alone or raise it to 24, never lower; separately, alignota-test.yml:66onto the same SHA-pinnedactions/setup-nodethe other 11 jobs use (it is the only floating tag in the repo).TARGET_VERSIONto 24 inensure-node.sh:14andNODE_VERSIONininstall.sh:44; moveMIN_VERSIONto 22; switchkirocrew-ec2.yaml:380off thepub_20.xNodeSource repo. Note thatinstall.sh:246-251(apt-get install -y nodejs npm/dnf install -y nodejs) andinstall.sh:254(brew install node) ignoreNODE_VERSIONentirely and then only report what landed — Debian/Ubuntu can install a Node far below any declared floor. Those paths need a post-install version assertion, not just a new constant._MIN_NODE_VERSION(22) imported bycli.py,cli_doctor.py, andcli_chat.pysodoctorandchatcan never disagree again; update the six user-facing message strings.@types/node. Re-pinwebsite/package.json:126from25.5.0to24.xso types match the runtime 1:1; givesite/an explicit pin so it stops floating (it is at 26.1.1 today); add annpmecosystem entry to.github/dependabot.ymlso this cannot silently diverge again.Makefile:33,minimal_install.sh:14,55,56,setup.sh:65,install.sh:339,docs/windows-install.md:35,docs/system-specs/modules/security.md:194(hardcodes "CI pins Node20.19.4"),docs/system-specs/modules/acp-client.md:19, andsite/src/SectionsBottom.tsx:74— the public marketing page says "Node.js 18+".node-version:in.github/workflows/**matches.nvmrc, plus a check that the pinned major is not past its EOL date innodejs/Release/schedule.json.^33.0.0onto a supported line (latest three majors) is a materially larger change (Chromium/V8 breaking changes,electron-builder@25may also need bumping, notarization and OTA update paths need re-testing). Tracking it here only as a dependency, not doing it in the same PR.Out of scope
docker/Dockerfilecontains no Node at all (FROM python:3.12-slim-trixie), yetfrontend.py:202,261andapps/backend.py:330,338callshutil.which("npm")/which("node")at runtime. That is a separate gap worth its own issue.node-versiongrep:codex-review.yml:98runsnpm install -g @openai/codexwith nosetup-nodestep at all, so the GPT review gate executes on whatever Node the runner ships; fouranthropics/claude-code-actioncall sites (claude-review.yml:132,design-review.yml:69,longterm-arbiter.yml:244,ux-review.yml:94) likewise. Separately,code-review.yml:218,nightly.yml:110, andrelease.yml:70inherit pinned Node through reusable-workflow calls — grepping those three files reports "no Node" and is wrong.node/npm/npxwith a presence check but no version check (record_browser.py:51,apps/registry.py:1751,mcp_playwright_proxy.py:640,scripts/check_npm_audit.py:173, and others). Worth its own issue because the failure mode is not just unversioned, it is silent:apps/backend.py:767-774logs and returnsNonewhen no node is found, androutes.py:952still answers the enable request with a success-shaped HTTP 200 — the problem only surfaces later as a generic502 "app '<name>' has no reachable backend"that names neither node nor the search order. The design-critique app degrades the same way, into a user-facing "couldn't see this screen" list rather than a diagnosable error.