From b073513d79c404137a98d8f6849a23a89f50837a Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 7 Aug 2026 18:30:14 -0400 Subject: [PATCH] change: gate the build toolchain by engines and embed the gated versions The rules come from one source, package.json engines, instead of copies in the build script. The payload then embeds the EXACT host versions the gates approved: the node dist is downloaded at the host node version (and must be an official nodejs.org release), the staged uv is the host binary, and npm ships inside the node dist. The installer moves to Node 26 so source installs and embedded installs run the same node major. --- .github/workflows/desktop-bundled-release.yml | 3 + nix/packages.nix | 2 +- scripts/build-bundled-desktop.mjs | 98 +++++++++++++++++-- scripts/install.ps1 | 2 +- scripts/install.sh | 2 +- 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 2e92fdd2ef95a..835f218db71dc 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -109,6 +109,9 @@ jobs: # ── Node ────────────────────────────────────────────────────────── - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: + # The build script embeds the EXACT host node version into the + # payload. 26 matches NODE_VERSION in scripts/install.sh, so an + # embedded install and a source install run the same node major. node-version: 26 cache: npm diff --git a/nix/packages.nix b/nix/packages.nix index 40316088c3d20..71333fae0412e 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -89,7 +89,7 @@ # nix run .#build-desktop-app-bundle -- --tag=vX.Y.Z build-desktop-app-bundle = pkgs.writeShellApplication { name = "build-desktop-app-bundle"; - runtimeInputs = [ pkgs.nodejs_22 pkgs.uv pkgs.git ]; + runtimeInputs = [ pkgs.nodejs_26 pkgs.uv pkgs.git ]; text = '' if [ ! -f scripts/build-bundled-desktop.mjs ]; then echo "error: run from a hermes-agent checkout root" >&2 diff --git a/scripts/build-bundled-desktop.mjs b/scripts/build-bundled-desktop.mjs index c86e8453c3d76..ba1450be2467d 100644 --- a/scripts/build-bundled-desktop.mjs +++ b/scripts/build-bundled-desktop.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node // build-bundled-desktop.mjs — build the fully bundled desktop installer -// locally, on any of the three platforms. This is the same sequence as -// .github/workflows/desktop-bundled-release.yml, in one runnable script: +// locally, on any of the three platforms. // // 1. preflight: uv, git, npm exist; a release tag is resolvable // 2. npm ci at the repo root // 3. build ui-tui (with hermes-ink) and the dashboard SPA -// 4. download the payload node dist for this platform (22.x) +// 4. download the payload node dist (the exact host node version) // 5. npm run build in apps/desktop with HERMES_DESKTOP_BUNDLED=1 // 6. npm run builder -- // @@ -27,7 +26,6 @@ import { fileURLToPath } from "node:url" import { hostTarBin } from "../apps/desktop/scripts/stage-agent-payloads.mjs" const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") -const PAYLOAD_NODE_MAJOR = "22" // matches NODE_VERSION in scripts/install.sh const args = process.argv.slice(2) const tagArg = args.find((a) => a.startsWith("--tag="))?.slice("--tag=".length) @@ -68,6 +66,86 @@ for (const tool of ["uv", "git", "npm", "tar"]) { } } +// Toolchain gates. The build's output depends on these tools, so a wrong +// version makes a silently different artifact (the first Windows build +// shipped a wrong-arch uv exactly this way). The rules come from ONE +// source — package.json "engines" — and the embedded runtimes are pinned +// to the EXACT host versions the gates approved: +// node — the payload node dist is downloaded at the host node version. +// uv — the staged uv IS the host binary, copied (stageUvAndPython). +// npm — ships inside the node dist; it cannot be chosen separately, +// so the host npm is gated by engines and the payload npm is +// whatever the pinned node dist bundles. +export function parseVersion(text) { + const match = String(text).match(/(\d+)\.(\d+)\.(\d+)/) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null +} + +export function compareVersions(a, b) { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return 0 +} + +// The subset of semver ranges that package.json engines actually uses: +// space-separated comparators AND together, `||` separates alternatives. +// An unparseable comparator fails closed. +export function satisfiesRange(version, range) { + return String(range).split("||").some((alternative) => { + const comparators = alternative.trim().split(/\s+/).filter(Boolean) + if (comparators.length === 0) return false + return comparators.every((comparator) => { + const m = comparator.match(/^(>=|<=|>|<|=)?v?(\d+)\.(\d+)\.(\d+)$/) + if (!m) return false + const cmp = compareVersions(version, [Number(m[2]), Number(m[3]), Number(m[4])]) + switch (m[1]) { + case ">=": return cmp >= 0 + case "<=": return cmp <= 0 + case ">": return cmp > 0 + case "<": return cmp < 0 + default: return cmp === 0 + } + }) + }) +} + +export function uvBannerProblem(banner) { + return /\([a-z0-9_]+-[a-z0-9]+-[a-z]+/.test(String(banner)) + ? null + : "its --version prints no build triple; the payload arch guard needs one (official uv 0.12+, or any nix/source build)" +} + +const engines = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).engines || {} + +// The approved host toolchain. Filled by the gates below; the payload +// stages embed THESE versions, so gate == embed by construction. +const HOST_TOOLCHAIN = { node: null, npm: null, uvBanner: null } + +for (const tool of ["node", "npm"]) { + const text = tool === "node" ? process.version : capture("npm --version") + const version = parseVersion(text) + if (!version) { + fail(`${tool}: cannot parse a version from ${JSON.stringify(text)}`) + } + const range = engines[tool] + if (range && !satisfiesRange(version, range)) { + fail(`${tool} ${version.join(".")} does not satisfy package.json engines ${JSON.stringify(range)} — the build would make a different artifact`) + } + HOST_TOOLCHAIN[tool] = version + console.log(`[build-bundled] ${tool} ${version.join(".")} (engines: ${range || "unconstrained"})`) +} + +{ + const uvBanner = capture("uv --version") + const problem = uvBannerProblem(uvBanner) + if (problem) { + fail(`uv (${uvBanner}) would make a broken artifact: ${problem}`) + } + HOST_TOOLCHAIN.uvBanner = uvBanner + console.log(`[build-bundled] ${uvBanner} (staged into the payload as-is)`) +} + let tag = tagArg if (!tag) { try { @@ -112,6 +190,7 @@ console.log(`[build-bundled] tag=${tag} platform=${process.platform}-${process.a // resolution for the workspace builds below. run("npm", ["ci", "--no-audit", "--no-fund"], { env: { + ...process.env, // spawnSync env REPLACES the child environment; keep PATH etc. "CI": "true" // skip annoying unicode install banner } }) @@ -119,17 +198,22 @@ run("npm", ["run", "build", "--workspace", "ui-tui"]) run("npm", ["run", "build", "--workspace", "web"]) // ── 4. payload node dist ──────────────────────────────────────────────────── +// Pinned to the EXACT host node version: the JS surfaces were built and +// npm-installed by the host node, and the payload node runs them at +// runtime. A different version is a different artifact. This also means +// the host node must be an official nodejs.org release — a patched build +// whose version does not exist upstream fails here, loudly. const distName = { linux: "linux", darwin: "darwin", win32: "win" }[process.platform] const distArch = { x64: "x64", arm64: "arm64" }[process.arch] const distExt = process.platform === "win32" ? "zip" : process.platform === "darwin" ? "tar.gz" : "tar.xz" +const version = `v${HOST_TOOLCHAIN.node.join(".")}` const index = JSON.parse( execSync(`curl -fsSL https://nodejs.org/dist/index.json`, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }) ) -const version = index.find((e) => e.version.startsWith(`v${PAYLOAD_NODE_MAJOR}.`))?.version -if (!version) { - fail(`no node ${PAYLOAD_NODE_MAJOR}.x release found in nodejs.org index`) +if (!index.some((e) => e.version === version)) { + fail(`host node ${version} is not an official nodejs.org release — cannot embed the exact build toolchain`) } const work = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-node-payload-")) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1a909101f9364..ed331bb8bbce5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -381,7 +381,7 @@ $PythonVersion = "3.11" # interpreters, so this list also matches a pre-existing system Python. Single # source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion. $PythonFallbackVersions = @("3.12", "3.13", "3.10") -$NodeVersion = "22" +$NodeVersion = "26" # The npm range the root package.json pins in `engines.npm`. A constant rather # than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo # is cloned, so there is usually no package.json on disk yet (and none at all diff --git a/scripts/install.sh b/scripts/install.sh index 23bb54ca6d973..2eec7de7d02f0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -57,7 +57,7 @@ else INSTALL_DIR_EXPLICIT=false fi PYTHON_VERSION="3.11" -NODE_VERSION="22" +NODE_VERSION="26" # FHS-style root install layout (set by resolve_install_layout when applicable): # code at /usr/local/lib/hermes-agent, command at /usr/local/bin/hermes,