diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 069de887eb81..588d7707ea76 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -65,7 +65,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm cache-dependency-path: website/package-lock.json diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 63766184dba8..cf775f89e032 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm cache-dependency-path: website/package-lock.json diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index ed0023399dca..b951c60eadb2 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -39,7 +39,7 @@ jobs: # ── Node ─────────────────────────────────────────────────────────── - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm - name: grab npm 12 diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index aa04d1b778da..d0fa3513e86c 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -67,7 +67,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm - name: grab npm 12 diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index c6f737631311..9119e0c7a9b9 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm - name: grab npm 12 @@ -66,7 +66,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 22 + node-version: 26 cache: npm - name: grab npm 12 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000000..6f4247a6255c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/.python-version b/.python-version new file mode 100644 index 000000000000..2c0733315e41 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Dockerfile b/Dockerfile index 0bb092ae85e3..2de6192715ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,14 +41,14 @@ RUN apt-get -o Acquire::Retries=3 update && \ make install FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source -# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x -# which reached EOL in April 2026 — we copy node + npm + corepack from the -# upstream node:22 image instead so we can stay on a supported LTS without -# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used -# so the produced binary links against glibc 2.36, which runs cleanly on -# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major -# is a one-line ARG change; see #4977. -FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source +# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x +# which reached EOL in April 2026 — we copy node + npm from the upstream +# node:26 image instead (Hermes pins its toolchain to Node 26 everywhere). +# Bookworm-based slim image used so the produced binary links +# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc +# 2.41) runtime. Bumping to a new Node major is a one-line ARG change; see +# #4977. +FROM node:26-bookworm-slim@sha256:9e6f9357d371591e32ab6f2d8a26d63bdd0d17c29eee3f4f3e7e454d9634bf73 AS node_source FROM debian:13.4 # Disable Python stdout buffering to ensure logs are printed immediately. @@ -70,7 +70,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright # hermes process, the dashboard, and per-profile gateways. RUN apt-get -o Acquire::Retries=3 update && \ apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ - ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \ + ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 procps git openssh-client docker-cli xz-utils && \ rm -rf /var/lib/apt/lists/* # Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the @@ -151,17 +151,20 @@ RUN useradd -u 10000 -m -d /opt/data hermes COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ -# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS -# installs from the upstream image. npm and npx are recreated as symlinks -# because they're symlinks in the source image (and need to live on PATH). +# Node 26: copy the node binary plus the bundled npm JS install from the +# upstream image. npm and npx are recreated as symlinks because they're +# symlinks in the source image (and need to live on PATH). +# +# No corepack: Node unbundled it upstream, so node:26 ships only npm in +# /usr/local/lib/node_modules. Nothing here needs it — no package.json +# declares a `packageManager`, and no build step shells out to yarn or pnpm. +# # See node_source stage at the top of the file for the version-bump # rationale (#4977). COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/ COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm -COPY --from=node_source /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ - ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ - ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx WORKDIR /opt/hermes diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 2671e7ccd32d..fc9bea59307b 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -35,6 +35,7 @@ from typing import Any, Dict, Optional from hermes_cli._subprocess_compat import windows_hide_flags +from hermes_constants import find_node_executable logger = logging.getLogger("agent.lsp.install") @@ -249,9 +250,12 @@ def _install_npm( peer deps that npm doesn't auto-pull (typescript-language-server needs ``typescript`` next to it; intelephense ships standalone). """ - npm = shutil.which("npm") + # Managed npm first: $HERMES_HOME/node is not on an arbitrary process's + # PATH, so a bare which() misses the Node that Hermes installed and + # reports "npm not on PATH" on a machine that has a perfectly good one. + npm = find_node_executable("npm") if npm is None: - logger.info("[install] cannot install %s: npm not on PATH", pkg) + logger.info("[install] cannot install %s: no usable npm found", pkg) return None staging = hermes_lsp_bin_dir().parent # /lsp/ install_targets = [pkg] + list(extra_pkgs or []) diff --git a/apps/desktop/electron/backend-env.test.ts b/apps/desktop/electron/backend-env.test.ts index a92ce6e062e7..6fb598cdfe01 100644 --- a/apps/desktop/electron/backend-env.test.ts +++ b/apps/desktop/electron/backend-env.test.ts @@ -7,6 +7,7 @@ import { appendUniquePathEntries, buildDesktopBackendEnv, buildDesktopBackendPath, + hermesManagedNodePathEntries, normalizeHermesHomeRoot, pathEnvKey, POSIX_SANE_PATH_ENTRIES @@ -22,8 +23,12 @@ test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entri }) const entries = result.split(':') - assert.equal(entries[0], '/Users/test/.hermes/node/bin') - assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin') + // Both managed-Node layouts lead, POSIX-native shape first, then the venv. + assert.deepEqual(entries.slice(0, 3), [ + '/Users/test/.hermes/node/bin', + '/Users/test/.hermes/node', + '/Users/test/.hermes/hermes-agent/venv/bin' + ]) assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added') assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added') assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added') @@ -33,6 +38,56 @@ test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entri } }) +test('managed Node dirs lead with the platform-native layout but always offer both', () => { + const posix = hermesManagedNodePathEntries('/Users/test/.hermes', { + platform: 'darwin', + pathModule: path.posix + }) + + const windows = hermesManagedNodePathEntries('C:\\Users\\test\\AppData\\Local\\hermes', { + platform: 'win32', + pathModule: path.win32 + }) + + // install.sh uses node/bin; install.ps1 unpacks node.exe into node\ itself. + // Both shapes are always emitted so migrated installs keep resolving. + assert.deepEqual(posix, ['/Users/test/.hermes/node/bin', '/Users/test/.hermes/node']) + assert.deepEqual(windows, [ + 'C:\\Users\\test\\AppData\\Local\\hermes\\node', + 'C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin' + ]) +}) + +test('managed Node dirs are empty without a Hermes home', () => { + assert.deepEqual(hermesManagedNodePathEntries(undefined, { platform: 'darwin', pathModule: path.posix }), []) + assert.deepEqual(hermesManagedNodePathEntries('', { platform: 'win32', pathModule: path.win32 }), []) +}) + +test('every managed Node dir outranks the inherited PATH on both platforms', () => { + for (const [platform, pathModule, home, inherited, delimiter] of [ + ['darwin', path.posix, '/Users/test/.hermes', '/usr/local/bin:/usr/bin', ':'], + ['win32', path.win32, 'C:\\hermes', 'C:\\Program Files\\nodejs;C:\\Windows\\System32', ';'] + ] as const) { + const entries = buildDesktopBackendPath({ + hermesHome: home, + venvRoot: null, + currentPath: inherited, + platform, + pathModule + }).split(delimiter) + + const managed = hermesManagedNodePathEntries(home, { platform, pathModule }) + const firstInherited = Math.min(...inherited.split(delimiter).map(entry => entries.indexOf(entry))) + + for (const dir of managed) { + assert.ok( + entries.indexOf(dir) >= 0 && entries.indexOf(dir) < firstInherited, + `${dir} must precede the inherited PATH on ${platform}` + ) + } + } +}) + test('desktop backend PATH preserves first occurrence and avoids duplicates', () => { const result = buildDesktopBackendPath({ hermesHome: '/Users/test/.hermes', @@ -64,7 +119,9 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () = }) assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath') - assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:')) + assert.ok( + env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/node:/Users/test/.hermes/hermes-agent/venv/bin:') + ) assert.ok(env.PATH.includes('/opt/homebrew/bin')) }) @@ -115,7 +172,13 @@ test('Windows PATH casing and delimiter are preserved without POSIX sane entries assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path') assert.equal(env.PATH, undefined) - assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;')) + // Windows leads with the portable layout (install.ps1 unpacks node.exe + // straight into node\, no bin\), then the POSIX shape for migrated installs. + assert.ok( + env.Path.startsWith( + 'C:\\Users\\test\\AppData\\Local\\hermes\\node;C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;' + ) + ) assert.ok(env.Path.includes('\\venv\\Scripts;')) assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows')) assert.equal(env.Path.includes('/opt/homebrew/bin'), false) diff --git a/apps/desktop/electron/backend-env.ts b/apps/desktop/electron/backend-env.ts index 3db4a19d0341..24d6928bad09 100644 --- a/apps/desktop/electron/backend-env.ts +++ b/apps/desktop/electron/backend-env.ts @@ -60,6 +60,34 @@ function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) { return ordered.join(delimiter) } +/** + * Hermes-managed Node.js directories, in preferred lookup order. + * + * There are two on-disk layouts. `scripts/install.ps1` unpacks portable Node + * straight into `%LOCALAPPDATA%\hermes\node` (node.exe at the root, no `bin\`); + * `scripts/install.sh` and the node-bootstrap helper use the POSIX + * `$HERMES_HOME/node/bin`. Emit BOTH on every platform so mixed and migrated + * installs resolve, leading with the layout native to the current platform. + * + * This is the single source of truth for the ordering rule on the Node side — + * `main.ts` imports it rather than keeping its own copy. Mirrors + * `iter_hermes_node_dirs()` in hermes_constants.py, which the Electron main + * process cannot import. + */ +function hermesManagedNodePathEntries( + hermesHome, + { platform = process.platform, pathModule = pathModuleForPlatform(platform) }: any = {} +) { + if (!hermesHome) { + return [] + } + + const root = pathModule.join(hermesHome, 'node') + const bin = pathModule.join(root, 'bin') + + return platform === 'win32' ? [root, bin] : [bin, root] +} + function buildDesktopBackendPath({ hermesHome, venvRoot, @@ -68,11 +96,11 @@ function buildDesktopBackendPath({ pathModule = pathModuleForPlatform(platform) }: any = {}) { const delimiter = delimiterForPlatform(platform) - const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null + const hermesNodeDirs = hermesManagedNodePathEntries(hermesHome, { platform, pathModule }) const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES - return appendUniquePathEntries([hermesNodeBin, venvBin, currentPath, saneEntries], { delimiter }) + return appendUniquePathEntries([hermesNodeDirs, venvBin, currentPath, saneEntries], { delimiter }) } function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) { @@ -126,6 +154,7 @@ export { buildDesktopBackendEnv, buildDesktopBackendPath, delimiterForPlatform, + hermesManagedNodePathEntries, normalizeHermesHomeRoot, pathEnvKey, POSIX_SANE_PATH_ENTRIES diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e0d43be1f623..834a67ece366 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -35,7 +35,7 @@ import { classifyActiveRuntime } from './active-runtime-state' import { stopBackendChild as stopBackendChildImpl } from './backend-child' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' -import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' +import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' import { isReauthRequiredError, waitForHermesReady } from './backend-health' import { canImportHermesCli, @@ -578,19 +578,10 @@ function resolveHermesHome() { const HERMES_HOME = resolveHermesHome() -function hermesManagedNodePathEntries() { - // NOTE: keep this ordering in sync with iter_hermes_node_dirs() in - // hermes_constants.py — this Node main process cannot import the Python - // module, so the platform-ordering rule is mirrored here. - const root = path.join(HERMES_HOME, 'node') - const bin = path.join(root, 'bin') - const entries = IS_WINDOWS ? [root, bin] : [bin, root] - - return entries.filter(directoryExists) -} - function pathWithHermesManagedNode(...entries) { - return [...hermesManagedNodePathEntries(), ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) + const managed = hermesManagedNodePathEntries(HERMES_HOME).filter(directoryExists) + + return [...managed, ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) } // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 518402303711..479219e9eb41 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,7 @@ "type": "module", "main": "dist/electron-main.mjs", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=26.0.0" }, "scripts": { "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", diff --git a/apps/desktop/vitest.setup.ts b/apps/desktop/vitest.setup.ts index a780ec2faa52..5e83926d5877 100644 --- a/apps/desktop/vitest.setup.ts +++ b/apps/desktop/vitest.setup.ts @@ -1,5 +1,33 @@ import { configure } from '@testing-library/react' +// Node 26 defines its own `localStorage` accessor on the global object, which +// returns `undefined` unless the process was started with --localstorage-file +// (it warns: "localStorage is not available because --localstorage-file was +// not provided"). In the jsdom environment `globalThis` IS the window, so that +// accessor shadows jsdom's Storage and every `localStorage.getItem(...)` in a +// test throws "Cannot read properties of undefined". Install a real in-memory +// Storage when the global resolves to nothing, before any test module reads it. +if (typeof (globalThis as any).localStorage === 'undefined') { + const store = new Map() + const storage: Storage = { + get length() { + return store.size + }, + key: (i: number) => [...store.keys()][i] ?? null, + getItem: (k: string) => store.get(String(k)) ?? null, + setItem: (k: string, v: string) => void store.set(String(k), String(v)), + removeItem: (k: string) => void store.delete(String(k)), + clear: () => store.clear(), + } + for (const target of [globalThis, (globalThis as any).window].filter(Boolean)) { + Object.defineProperty(target, 'localStorage', { + value: storage, + configurable: true, + writable: true, + }) + } +} + // React 19 + Testing Library 16: opt into the act environment so render(), // fireEvent(), and findBy* queries automatically flush state updates without // spurious "not wrapped in act(...)" warnings. diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index 3f9cff71cce1..1c87f7aa88cf 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -21,13 +21,16 @@ import sys from pathlib import Path -from hermes_constants import agent_browser_runnable +from hermes_constants import agent_browser_runnable, find_node_executable from tools.environments.local import hermes_subprocess_env _IS_WINDOWS = platform.system() == "Windows" _DEP_CHECKS = { - "node": lambda: shutil.which("node") is not None, + # find_node_executable() rather than a bare which(): $HERMES_HOME/node is + # not on PATH, so which() would report Node missing on an install that has + # a managed one and trigger a redundant re-install. + "node": lambda: find_node_executable("node") is not None, "browser": lambda: ( agent_browser_runnable(shutil.which("agent-browser")) or _has_system_browser() diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1d68388fe2e5..1852afbd8b8f 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2749,6 +2749,56 @@ def _systemd_watchdog_service_fields( return "notify", f"NotifyAccess=main\nWatchdogSec={seconds}s\n" +def _append_node_dir_for_service( + path_entries: list[str], hermes_root: Path | None = None +) -> None: + """Add the Node directory a generated service unit should use to *path_entries*. + + The Hermes-managed Node under ``$HERMES_HOME/node`` goes first when it + exists. A bare ``shutil.which("node")`` cannot be trusted on its own here: + a service unit is written once and then survives reboots, so resolving a + system Node that happens to be ahead on the installing shell's PATH bakes + the wrong interpreter in permanently — the exact failure the desktop + backend spawn was fixed for. Managed dirs are profile-scoped, so each + profile's unit still names its own Node. + + *hermes_root* is the Hermes home the unit will run against. System units + installed via sudo MUST pass the **target user's** home: probing the + default (the calling user's — root's — tree) would bake root's Node into + the target user's unit. The probe swallows OSError: an unreadable + candidate dir (hardened home) means "skip the rung", not "crash the + generator". + + PATH lookup remains the fallback rung for installs with no managed Node. + """ + from hermes_constants import iter_hermes_node_dirs + + for directory in iter_hermes_node_dirs(hermes_root): + entry = str(directory) + try: + present = directory.is_dir() + except OSError: + present = False + if present and entry not in path_entries: + path_entries.append(entry) + + resolved_node = shutil.which("node") + if not resolved_node: + return + + # Use the directory where ``node`` is *found on PATH*, NOT the symlink's + # resolved target. ``~/.local/bin/node`` is often a symlink into a + # specific profile's node install (e.g. profiles/jarvis/node/bin/node); + # calling .resolve() here would chase that symlink and bake one profile's + # node path into *every* profile's service unit. That cross-profile leak + # makes systemd_unit_is_current() perpetually false, so each gateway + # rewrites its unit + daemon-reload on every boot. Using the symlink's own + # parent keeps the generated unit profile-agnostic. + resolved_node_dir = str(Path(resolved_node).parent) + if resolved_node_dir not in path_entries: + path_entries.append(resolved_node_dir) + + def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str: python_path = get_python_path() working_dir = _stable_service_working_dir() @@ -2756,19 +2806,11 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") path_entries = _build_service_path_dirs() - resolved_node = shutil.which("node") - if resolved_node: - # Use the directory where ``node`` is *found on PATH*, NOT the - # symlink's resolved target. ``~/.local/bin/node`` is often a symlink - # into a specific profile's node install (e.g. profiles/jarvis/node/ - # bin/node); calling .resolve() here would chase that symlink and bake - # one profile's node path into *every* profile's service unit. That - # cross-profile leak makes systemd_unit_is_current() perpetually false, - # so each gateway rewrites its unit + daemon-reload on every boot. Using - # the symlink's own parent keeps the generated unit profile-agnostic. - resolved_node_dir = str(Path(resolved_node).parent) - if resolved_node_dir not in path_entries: - path_entries.append(resolved_node_dir) + if not system: + # System units append the managed Node dirs later, once the TARGET + # user's Hermes home is known — probing here would stat the calling + # (sudo → root's) tree and bake the wrong user's Node into the unit. + _append_node_dir_for_service(path_entries) common_bin_paths = [ "/usr/local/sbin", @@ -2802,6 +2844,17 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir) venv_dir = _remap_path_for_user(venv_dir, home_dir) path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries] + # Managed Node for the TARGET user's tree (see the skip above): probe + # the remapped hermes_home, not the calling user's. Prepend — the + # managed Node must outrank remapped shell-PATH entries, matching the + # user-unit ordering where it's appended before PATH capture. + _target_node_entries: list[str] = [] + _append_node_dir_for_service( + _target_node_entries, Path(hermes_home) if hermes_home else None + ) + path_entries = [ + e for e in _target_node_entries if e not in path_entries + ] + path_entries path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries)) path_entries.extend(_build_wsl_interop_paths(path_entries)) path_entries.extend(common_bin_paths) @@ -3961,17 +4014,7 @@ def generate_launchd_plist() -> str: # Resolve the directory containing the node binary (e.g. Homebrew, nvm) # so it's explicitly in PATH even if the user's shell PATH changes later. priority_dirs = _build_service_path_dirs() - resolved_node = shutil.which("node") - if resolved_node: - # Use the directory where ``node`` is *found on PATH*, NOT the symlink's - # resolved target. ``~/.local/bin/node`` is often a symlink into a - # specific profile's node install; calling .resolve() would chase it and - # bake one profile's path into every profile's service definition, - # breaking profile isolation and causing perpetual unit rewrites. See - # the matching fix in generate_systemd_unit(). - resolved_node_dir = str(Path(resolved_node).parent) - if resolved_node_dir not in priority_dirs: - priority_dirs.append(resolved_node_dir) + _append_node_dir_for_service(priority_dirs) sane_path = ":".join( dict.fromkeys( priority_dirs + [p for p in os.environ.get("PATH", "").split(":") if p] diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9b4611b3add0..e3b8986c670c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1938,12 +1938,18 @@ def _node_bin(bin: str) -> str: env_node = os.environ.get("HERMES_NODE") if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK): return env_node - path = shutil.which(bin) + # find_node_executable() prefers the managed $HERMES_HOME/node tree, + # which is not on PATH — a bare which() would declare "node not found" + # and exit on an install whose only Node is the one Hermes installed, + # and would pick a system Node over the managed one when both exist. + from hermes_constants import find_node_executable + + path = find_node_executable(bin) if not path and bin == "node": try: from hermes_cli.dep_ensure import ensure_dependency if ensure_dependency("node"): - path = shutil.which("node") + path = find_node_executable("node") except Exception: pass if not path: diff --git a/hermes_cli/npm_engine.py b/hermes_cli/npm_engine.py index 49ad23a792c8..c33572894e76 100644 --- a/hermes_cli/npm_engine.py +++ b/hermes_cli/npm_engine.py @@ -5,7 +5,7 @@ ``npm ci`` / ``npm install`` we run inside the checkout:: npm error code EBADENGINE - npm error notsup Required: {"node":">=20.0.0","npm":">=11.17.0"} + npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"} npm error notsup Actual: {"npm":"10.9.8","node":"v22.23.1"} Rather than predicting the failure (which would mean a semver range matcher and diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 6fe25a3fdb11..d18c813f5e25 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1563,7 +1563,14 @@ def setup_terminal_backend(config: dict): print_info("Installing vercel SDK...") import subprocess - uv_bin = shutil.which("uv") + # Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare + # which() misses the uv Hermes installed. Bootstrapping one is + # welcome here — this is the interactive setup wizard, already + # mid-install, and the alternative tier is a pip that a `uv venv` + # venv may not even have. + from hermes_cli.managed_uv import ensure_uv + + uv_bin = ensure_uv() if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "--python", sys.executable, "vercel"], diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index dab26dbd9149..d36a38685f07 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -790,7 +790,15 @@ def _pip_install( venv_root = Path(sys.executable).parent.parent uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} - uv_bin = shutil.which("uv") + # Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare which() + # misses the uv Hermes installed and prefers a system one when both exist. + # ensure_uv() rather than a pure lookup because this runs during setup, + # where installing uv is in scope — and tier 2 is a pip that the Windows + # installer's `uv venv` does not seed, so failing to find uv here is the + # difference between a working post-setup hook and "No module named pip". + from hermes_cli.managed_uv import ensure_uv + + uv_bin = ensure_uv() if uv_bin: try: result = subprocess.run( @@ -1608,10 +1616,15 @@ def _kill_installer_tree(proc): def _run_post_setup(post_setup_key: str): """Run post-setup hooks for tools that need extra installation steps.""" import shutil + from hermes_constants import find_node_executable + if post_setup_key in {"agent_browser", "browserbase"}: node_modules = PROJECT_ROOT / "node_modules" / "agent-browser" - npm_bin = shutil.which("npm") - npx_bin = shutil.which("npx") + # Managed Node first — $HERMES_HOME/node is not on PATH, so a bare + # which() reports "no npm" on installs whose only Node is the one + # Hermes installed for exactly this toolchain. + npm_bin = find_node_executable("npm") + npx_bin = find_node_executable("npx") # Step 1: install the agent-browser npm package into node_modules/ if not node_modules.exists() and npm_bin: _print_info(" Installing Node.js dependencies for browser tools...") @@ -1729,7 +1742,7 @@ def _run_post_setup(post_setup_key: str): elif post_setup_key == "camofox": camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser" - _npm_bin = shutil.which("npm") + _npm_bin = find_node_executable("npm") if camofox_dir.exists(): _print_success(" Camofox already installed, nothing to do") elif _npm_bin: @@ -1751,7 +1764,7 @@ def _run_post_setup(post_setup_key: str): _print_info(" npx @askjo/camofox-browser") _print_info(" First run downloads the Camoufox engine (~300MB)") _print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") - elif not shutil.which("npm"): + elif not _npm_bin: _print_warning(" Node.js not found. Install Camofox via Docker:") _print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") diff --git a/hermes_constants.py b/hermes_constants.py index dc0de54481a8..f8e43b460ffe 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -294,8 +294,9 @@ def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]: dirs = [root / "node"] bin_dir = root / "node" / "bin" # NOTE: keep this ordering in sync with hermesManagedNodePathEntries() in - # apps/desktop/electron/main.cjs — the Electron main process is Node and - # cannot import this module, so the platform-ordering rule is mirrored there. + # apps/desktop/electron/backend-env.ts — the Electron main process is Node + # and cannot import this module, so the platform-ordering rule is mirrored + # there (once; main.ts imports it rather than keeping its own copy). if sys.platform == "win32": return dirs + [bin_dir] return [bin_dir] + dirs @@ -316,7 +317,7 @@ def _candidate_node_command_names(command: str) -> list[str]: return [f"{base}.cmd", f"{base}.exe", base] -_HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "22")) +_HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "26")) _managed_node_heal_attempted = False _NODE_BOOTSTRAP_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lib" / "node-bootstrap.sh" @@ -550,21 +551,54 @@ def heal_hermes_managed_node() -> bool: return result.returncode == 0 -def find_hermes_node_executable(command: str) -> str | None: - """Return a Hermes-managed Node/npm executable path, healing broken trees.""" - names = _candidate_node_command_names(command) - broken_present = False - for directory in iter_hermes_node_dirs(): - for name in names: +def _managed_node_tree_outdated(home: Path | None = None) -> bool: + """Return True when the managed tree's node runs but is below the target major. + + An outdated managed Node (e.g. a 22 tree from an older install) heals the + same way a broken one does: :func:`find_hermes_node_executable` triggers + the once-per-process heal, which redownloads + ``latest-v{_HERMES_NODE_TARGET_MAJOR}.x`` — so existing users are upgraded + on next launch, not just on the next installer re-run. Mirrors + ``_nb_managed_node_outdated`` in ``scripts/lib/node-bootstrap.sh``. + """ + import subprocess + + for directory in iter_hermes_node_dirs(home): + for name in _candidate_node_command_names("node"): candidate = directory / name - if candidate.is_file() and ( - sys.platform == "win32" or os.access(candidate, os.X_OK) + if not candidate.is_file() or ( + sys.platform != "win32" and not os.access(candidate, os.X_OK) ): - resolved = str(candidate) - if node_tool_runnable(resolved): - return resolved - broken_present = True - if broken_present and heal_hermes_managed_node(): + continue + try: + from hermes_cli._subprocess_compat import windows_hide_flags + + result = subprocess.run( + [str(candidate), "--version"], + capture_output=True, + timeout=10, + creationflags=windows_hide_flags(), + ) + major = int(result.stdout.decode().strip().lstrip("v").split(".")[0]) + except (OSError, subprocess.TimeoutExpired, ValueError, IndexError): + return False # broken, not outdated — the runnable probe handles it + return major < _HERMES_NODE_TARGET_MAJOR + return False + + +def find_hermes_node_executable(command: str) -> str | None: + """Return a Hermes-managed Node/npm executable path, healing broken trees. + + Outdated trees (node major below ``_HERMES_NODE_TARGET_MAJOR``) heal the + same way broken ones do — the once-per-process heal redownloads the target + major, upgrading existing users on next launch rather than next reinstall. + When the heal fails (offline, download error), an outdated-but-runnable + tree is still returned: old Node beats no Node. + """ + names = _candidate_node_command_names(command) + + def _first_runnable() -> tuple[str | None, bool]: + broken = False for directory in iter_hermes_node_dirs(): for name in names: candidate = directory / name @@ -573,8 +607,19 @@ def find_hermes_node_executable(command: str) -> str | None: ): resolved = str(candidate) if node_tool_runnable(resolved): - return resolved - return None + return resolved, broken + broken = True + return None, broken + + resolved, broken_present = _first_runnable() + needs_heal = broken_present or ( + resolved is not None and _managed_node_tree_outdated() + ) + if needs_heal and heal_hermes_managed_node(): + healed, _ = _first_runnable() + if healed: + return healed + return resolved def find_node_executable_on_path(command: str) -> str | None: diff --git a/nix/checks.nix b/nix/checks.nix index 9e69d4c0f832..1cf34212597a 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -256,8 +256,8 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; - # Verify HERMES_NODE is set in wrapper and points to Node 20+ - # (string-width uses the /v regex flag which requires Node 20+) + # Verify HERMES_NODE is set in wrapper and points to Node 26+ + # (Hermes pins its toolchain to Node 26 everywhere) hermes-node = pkgs.runCommand "hermes-node-version" { } '' set -e echo "=== Checking HERMES_NODE in wrapper ===" @@ -270,9 +270,9 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "PASS: HERMES_NODE executable at $HERMES_NODE" NODE_MAJOR=$("$HERMES_NODE" --version | sed 's/^v//' | cut -d. -f1) - test "$NODE_MAJOR" -ge 20 || \ - (echo "FAIL: Node v$NODE_MAJOR < 20, TUI needs /v regex flag support"; exit 1) - echo "PASS: Node v$NODE_MAJOR >= 20" + test "$NODE_MAJOR" -ge 26 || \ + (echo "FAIL: Node v$NODE_MAJOR < 26, Hermes requires Node 26"; exit 1) + echo "PASS: Node v$NODE_MAJOR >= 26" echo "=== All HERMES_NODE checks passed ===" mkdir -p $out diff --git a/package-lock.json b/package-lock.json index d3f16014caa9..337d5ef1e0d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,8 +29,8 @@ "typescript-eslint": "8.64.0" }, "engines": { - "node": ">=20.0.0", - "npm": ">=11.17.0" + "node": ">=26.0.0", + "npm": ">=12.0.0" } }, "apps/bootstrap-installer": { @@ -168,7 +168,7 @@ "wait-on": "9.0.10" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=26.0.0" } }, "apps/desktop/node_modules/ignore": { diff --git a/package.json b/package.json index 7a5e95a126ec..a22d0f1b8e66 100644 --- a/package.json +++ b/package.json @@ -53,8 +53,8 @@ "brace-expansion": "5.0.8" }, "engines": { - "node": ">=20.0.0", - "npm": ">=11.17.0" + "node": ">=26.0.0", + "npm": ">=12.0.0" }, "allowScripts": { "unicode-animations": false, diff --git a/scripts/ci/test_install_ps1_path_migration.ps1 b/scripts/ci/test_install_ps1_path_migration.ps1 new file mode 100644 index 000000000000..232d49fb2478 --- /dev/null +++ b/scripts/ci/test_install_ps1_path_migration.ps1 @@ -0,0 +1,125 @@ +# Behavioral test for install.ps1's persisted-User-PATH migration. +# +# Run: pwsh -NoProfile -File scripts/ci/test_install_ps1_path_migration.ps1 +# +# Not wired into the default CI lane — the Linux runners have no PowerShell +# host. It runs on any machine with pwsh (including via nixpkgs#powershell), +# and on a Windows runner if one is ever added. +# +# This is NOT a source-regex test. It parses install.ps1, lifts the real +# Set-ManagedNodeFirstOnUserPath body out of the AST, and rewrites *only* the +# two registry calls into an in-memory store so the actual shipped logic — +# split, dedupe, prepend, change-detection — executes for real. Rewriting from +# the AST rather than hand-copying the body means the test cannot silently +# drift away from the function it claims to cover. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$installPs1 = Join-Path $PSScriptRoot '..' 'install.ps1' | Resolve-Path +$ast = [System.Management.Automation.Language.Parser]::ParseFile( + $installPs1, [ref]$null, [ref]$null) + +$fn = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -eq 'Set-ManagedNodeFirstOnUserPath' +}, $true) + +if (-not $fn) { + throw "Set-ManagedNodeFirstOnUserPath not found in $installPs1" +} + +# Swap the two registry calls for the in-memory store. Both must match, or the +# function has changed shape and this harness is no longer exercising it. +# Rewrite the whole definition extent (which already carries `function +# { param(...) ... }`) so the shipped param block and body run verbatim. +$definition = $fn.Extent.Text +$reads = ([regex]'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)').Matches($definition).Count +$writes = ([regex]'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)').Matches($definition).Count +if ($reads -ne 1 -or $writes -ne 1) { + throw "expected exactly one User PATH read and one write in the function body; found $reads read(s), $writes write(s). Update this harness." +} + +$definition = $definition -replace ` + '\[Environment\]::GetEnvironmentVariable\("Path", "User"\)', '$script:FakeUserPath' +$definition = $definition -replace ` + '\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)', '$script:FakeUserPath = $1; $script:FakeWrites++' + +Invoke-Expression $definition + +$NODE = 'C:\Users\me\AppData\Local\hermes\node' +$script:Failures = 0 + +function Invoke-Migration { + param([string]$Start, [string]$NodeDir = $NODE) + $script:FakeUserPath = $Start + $script:FakeWrites = 0 + Set-ManagedNodeFirstOnUserPath $NodeDir +} + +function Assert-Equal { + param($Expected, $Actual, [string]$Name) + if ($Expected -ceq $Actual) { + Write-Host " PASS $Name" + } else { + Write-Host " FAIL $Name" + Write-Host " expected: [$Expected]" + Write-Host " actual: [$Actual]" + $script:Failures++ + } +} + +Write-Host "install.ps1 Set-ManagedNodeFirstOnUserPath" + +# The regression this function exists for: an install made by an older +# install.ps1, which *appended*. A system Node leads and the managed dir is +# stranded at the tail, so every new shell resolves the wrong node.exe. An +# add-if-missing check would see the entry present and leave it there forever. +Invoke-Migration "C:\Program Files\nodejs;C:\Users\me\bin;$NODE" +Assert-Equal "$NODE;C:\Program Files\nodejs;C:\Users\me\bin" $script:FakeUserPath ` + 'upgrade from appending installer: managed dir becomes first entry' +Assert-Equal 1 (@($script:FakeUserPath -split ';' | Where-Object { $_ -eq $NODE }).Count) ` + 'upgrade: managed dir is not duplicated' +Assert-Equal "C:\Program Files\nodejs;C:\Users\me\bin" ` + (($script:FakeUserPath -split ';' | Where-Object { $_ -ne $NODE }) -join ';') ` + 'upgrade: unrelated entries keep their relative order' +Assert-Equal 1 $script:FakeWrites 'upgrade: persists exactly once' + +Invoke-Migration "$NODE;C:\Program Files\nodejs" +Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'already correct: unchanged' +Assert-Equal 0 $script:FakeWrites 'already correct: no registry write' + +Invoke-Migration "C:\Program Files\nodejs" +Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'fresh install: prepended' + +# Empty segments are legal in a real User PATH (a trailing ';' is common) and +# the installer's other PATH code preserves them. Migration must not quietly +# rewrite parts of PATH it was not asked to touch. +Invoke-Migration "C:\Program Files\nodejs;;C:\Users\me\bin;" +Assert-Equal "$NODE;C:\Program Files\nodejs;;C:\Users\me\bin;" $script:FakeUserPath ` + 'empty segments are preserved' + +# Windows paths are case-insensitive, and -ne on strings is too. +Invoke-Migration "C:\Program Files\nodejs;c:\users\me\appdata\local\HERMES\Node" +Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath ` + 'existing entry in different case is replaced, not duplicated' + +Invoke-Migration "$NODE;C:\Program Files\nodejs;$NODE" +Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'duplicates collapse' + +Invoke-Migration "" +Assert-Equal $NODE $script:FakeUserPath 'empty User PATH' + +Invoke-Migration "C:\Program Files\nodejs" "" +Assert-Equal "C:\Program Files\nodejs" $script:FakeUserPath 'empty NodeDir is a no-op' +Assert-Equal 0 $script:FakeWrites 'empty NodeDir does not write' + +if ($script:Failures -gt 0) { + Write-Host "" + Write-Host "$script:Failures assertion(s) failed" + exit 1 +} + +Write-Host "" +Write-Host "all assertions passed" diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b39464f42c26..95dd40d24b9d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -150,7 +150,14 @@ $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 +# when install.ps1 is piped straight from the web). Get-NpmRange prefers the +# manifest whenever it does exist, so a drifted constant self-corrects on any +# run against an existing checkout. +$NpmRange = ">=12.0.0" # Stage-protocol version. Bumped only for genuinely breaking changes to the # manifest schema, stage-name set semantics, or stdout JSON shape. Adding a @@ -528,6 +535,135 @@ function Ensure-NodeExeOnPath { return $true } +# Put the Hermes-managed Node dir at the FRONT of the persisted User PATH. +# +# Appending is not enough: it leaves a pre-existing system Node ahead of the +# bundled one in every new shell, so anything launched without a curated +# environment (a standalone hermes-setup.exe run, a user typing `npm`) silently +# resolves the wrong Node. Bundled must win. +# +# Move-to-front rather than add-if-missing, because installs made by an older +# install.ps1 already have this dir in User PATH -- at the tail. An +# add-if-missing check sees it present and leaves the broken ordering in place +# forever, so the very users the ordering bug hurt would never be repaired. +# +# Unrelated entries keep their relative order, including empty segments (a +# trailing ';' is legal and common in a real User PATH; Install-Git's splitting +# preserves them too, so this must not quietly rewrite them). Duplicate +# occurrences of the managed dir collapse into the single leading entry. +# PowerShell's -ne is case-insensitive for strings, which is the right +# comparison on Windows. Persists only when the resulting string differs, so +# an already-correct PATH costs one registry read and no write. +function Set-ManagedNodeFirstOnUserPath { + param([string]$NodeDir) + + if (-not $NodeDir) { return } + + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $items = if ($userPath) { @($userPath -split ";") } else { @() } + + $rest = @($items | Where-Object { $_ -ne $NodeDir }) + $updated = (@($NodeDir) + $rest) -join ";" + + if ($updated -ne $userPath) { + [Environment]::SetEnvironmentVariable("Path", $updated, "User") + } +} + +# The npm range to install into the managed Node tree. Prefers the checkout's +# root package.json so the installer and the manifest cannot drift; falls back +# to the $NpmRange constant, which is the common case here because Test-Node +# runs before the repo is cloned. +function Get-NpmRange { + $manifest = Join-Path $InstallDir "package.json" + if (Test-Path $manifest) { + try { + $engines = (Get-Content $manifest -Raw | ConvertFrom-Json).engines + if ($engines -and $engines.npm) { return [string]$engines.npm } + } catch { } + } + return $NpmRange +} + +# Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange. +# +# The nodejs.org zip ships whatever npm that Node major bundles -- Node 26.5.1 +# bundles npm 11.17.0, one minor below the root package.json's own +# `engines.npm` floor of >=12. The repo .npmrc sets `engine-strict=true`, so +# that is fatal rather than a warning and a brand-new install dies at the first +# `npm ci` with EBADENGINE. Provision the right npm here instead of reacting +# to the failure later. +# +# Three details are load-bearing, mirroring _nb_ensure_bundled_npm_range in +# scripts/lib/node-bootstrap.sh and upgrade_managed_npm in +# hermes_cli/npm_engine.py: +# - a temp cwd, so the checkout's own .npmrc (engine-strict, +# min-release-age) does not gate the very upgrade meant to satisfy it; +# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc; +# - an explicit --prefix at the managed tree, so the upgrade rewrites the +# tree's own npm rather than installing a second copy elsewhere. +# +# Best-effort: a failure leaves a working Node with an old npm, which beats no +# Node at all, and npm_engine.py still covers the EBADENGINE that follows. +function Update-ManagedNpm { + param([string]$NodeDir) + + $npmCmd = Join-Path $NodeDir "npm.cmd" + if (-not (Test-Path $npmCmd)) { return $false } + + $range = Get-NpmRange + + # Skip the network round-trip when the bundled npm already satisfies the + # range. Only the ">=N" shape we actually author is parsed; anything more + # exotic falls through to letting npm itself decide. + if ($range -match '^>=(\d+)') { + $want = [int]$Matches[1] + try { + $have = (& $npmCmd --version 2>$null) + if ($have -match '^(\d+)') { + if ([int]$Matches[1] -ge $want) { return $true } + } + } catch { } + } + + Write-Info "Upgrading bundled npm to satisfy $range ..." + + $tmpCwd = Join-Path $env:TEMP ("hermes-npm-upgrade-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $tmpCwd | Out-Null + $prevAge = $env:npm_config_min_release_age + $prevCI = $env:CI + $prevEAP = $ErrorActionPreference + Push-Location $tmpCwd + try { + $env:npm_config_min_release_age = "0" + $env:CI = "1" + # Relax EAP=Stop so npm's stderr lines don't get wrapped as + # ErrorRecords and short-circuit before $LASTEXITCODE is checked. + # Same pattern as Install-Uv. + $ErrorActionPreference = "Continue" + & $npmCmd install --global --prefix $NodeDir "npm@$range" ` + --no-fund --no-audit --progress=false 2>&1 | Out-Null + $exit = $LASTEXITCODE + } catch { + $exit = 1 + } finally { + $ErrorActionPreference = $prevEAP + Pop-Location + $env:npm_config_min_release_age = $prevAge + $env:CI = $prevCI + Remove-Item -Recurse -Force $tmpCwd -ErrorAction SilentlyContinue + } + + if ($exit -ne 0) { + Write-Warn "Could not upgrade bundled npm to $range -- ``npm ci`` may fail with EBADENGINE." + Write-Info "Fix manually: npm install -g --prefix `"$NodeDir`" npm@`"$range`"" + return $false + } + + Write-Success "npm $(& $npmCmd --version 2>$null) installed" + return $true +} + # Re-discover uv without re-installing it. Cross-process stage drivers # (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage # in a fresh powershell process, so $script:UvCmd set by Install-Uv in a @@ -1054,11 +1190,10 @@ function Set-GitBashEnvVar { Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path." } -# The desktop build runs Vite ^8, which refuses to start on Node outside -# `^20.19 || >=22.12` -- older Node lacks node:util.styleText, so `vite build` -# crashes with a SyntaxError that surfaces only as the opaque "Build desktop -# app ... exit code 1" install failure. Returns $true when a `node --version` -# string clears that floor. +# Hermes requires Node 26 across every install: the desktop build's toolchain +# floor is pinned there and the managed runtime, heal, and upgrade paths all +# provision latest-v26.x. Returns $true when a `node --version` string clears +# that floor. function Test-NodeVersionOk { param([string]$Version) try { @@ -1066,9 +1201,7 @@ function Test-NodeVersionOk { } catch { return $false } - if ($v.Major -eq 20 -and $v.Minor -ge 19) { return $true } - if ($v.Major -ge 22 -and ($v.Major -gt 22 -or $v.Minor -ge 12)) { return $true } - return $false + return ($v.Major -ge 26) } function Test-Node { @@ -1082,7 +1215,7 @@ function Test-Node { $script:HasNode = $true return $true } - Write-Warn "Node.js $version is too old for the desktop build (need ^20.19 or >=22.12)" + Write-Warn "Node.js $version is too old (Hermes requires Node >=26)" } # Prefer a Hermes-managed Node from a previous run over a too-old system one. @@ -1090,7 +1223,12 @@ function Test-Node { if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) { $version = & $managedNode --version $env:Path = "$HermesHome\node;$env:Path" + Set-ManagedNodeFirstOnUserPath "$HermesHome\node" Write-Success "Node.js $version found (Hermes-managed)" + # A tree from an older install still has that Node major's bundled + # npm, which is below the current engines.npm floor. No-ops when the + # npm is already in range, so reruns cost one --version probe. + Update-ManagedNpm "$HermesHome\node" | Out-Null $script:HasNode = $true return $true } @@ -1132,17 +1270,15 @@ function Test-Node { # Persist to User PATH so fresh shells (and future stages # in cross-process driver mode) see it. Matches the - # pattern Install-Git uses for PortableGit. - $nodeDir = "$HermesHome\node" - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - $userPathItems = if ($userPath) { $userPath -split ";" } else { @() } - if ($userPathItems -notcontains $nodeDir) { - $userPathItems += $nodeDir - [Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User") - } + # pattern Install-Git uses for PortableGit. See + # Set-ManagedNodeFirstOnUserPath for why this is a + # move-to-front and not an add-if-missing. + Set-ManagedNodeFirstOnUserPath "$HermesHome\node" $version = & "$HermesHome\node\node.exe" --version Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)" + # The zip's bundled npm is below the repo's engines.npm floor. + Update-ManagedNpm "$HermesHome\node" | Out-Null $script:HasNode = $true Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue @@ -1176,7 +1312,7 @@ function Test-Node { # even after a "successful" install. The OpenJS manifest does # publish an arm64 installer, so this is safe. $wingetArgs = @( - 'install','OpenJS.NodeJS.LTS','--silent', + 'install','OpenJS.NodeJS','--silent', '--accept-package-agreements','--accept-source-agreements' ) if ((Get-WindowsArch) -eq 'arm64') { @@ -2891,7 +3027,7 @@ function Install-Desktop { # Always re-resolve Node here. Stages run in separate PowerShell processes, # so $script:HasNode from Stage-Node isn't visible; more importantly Test-Node - # enforces the build floor (^20.19 || >=22.12) and prepends the Hermes-managed + # enforces the build floor (Node >=26) and prepends the Hermes-managed # Node to PATH, so the build never runs on a too-old system Node -- the cause # of the opaque "Build desktop app ... exit code 1" failure (Vite crashes on # old Node). diff --git a/scripts/install.sh b/scripts/install.sh index 071081b04562..38198eab463b 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, @@ -780,21 +780,16 @@ check_git() { exit 1 } -# The desktop build runs Vite ^8, which refuses to start on Node outside -# `^20.19 || >=22.12` — older Node lacks `node:util.styleText`, so `vite build` -# crashes with a SyntaxError that surfaces only as the opaque "Build desktop -# app … exit code 1" install failure. Returns 0 when the given `node --version` -# string clears that floor; anything below it is replaced with the Hermes- -# managed Node $NODE_VERSION LTS. +# Hermes requires Node 26 across every install: the desktop build's toolchain +# floor is pinned there and the managed runtime, heal, and upgrade paths all +# provision latest-v26.x. Returns 0 when the given `node --version` string +# clears that floor; anything below it is replaced with the Hermes-managed +# Node $NODE_VERSION. node_satisfies_build() { local ver="${1#v}" local major="${ver%%.*}" - local minor="${ver#*.}"; minor="${minor%%.*}" case "$major" in ''|*[!0-9]*) return 1 ;; esac - case "$minor" in ''|*[!0-9]*) minor=0 ;; esac - if [ "$major" -eq 20 ] && [ "$minor" -ge 19 ]; then return 0; fi - if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 12 ]; }; then return 0; fi - return 1 + [ "$major" -ge 26 ] } check_node() { @@ -820,7 +815,7 @@ check_node() { fi if command -v node &> /dev/null; then - log_warn "Node.js $(node --version) is too old for the desktop build (need ^20.19 or >=22.12) — installing Hermes-managed Node $NODE_VERSION LTS..." + log_warn "Node.js $(node --version) is too old (Hermes requires Node >=26) — installing Hermes-managed Node $NODE_VERSION..." elif [ "$DISTRO" = "termux" ]; then log_info "Node.js not found — installing Node.js via pkg..." else @@ -869,7 +864,7 @@ install_node() { ;; esac - # Resolve the latest v22.x.x tarball name from the index page + # Resolve the latest v${NODE_VERSION}.x.x tarball name from the index page local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/" local tarball_name tarball_name=$(curl -fsSL "$index_url" \ @@ -2928,7 +2923,7 @@ install_desktop() { # with no app and a confusing "couldn't find a built desktop" at launch. # Always re-resolve Node here. Stages run in separate processes, so we can't # trust an earlier check; more importantly check_node now enforces the build - # floor (^20.19 || >=22.12) and prepends the Hermes-managed Node to PATH, so + # floor (Node >=26) and prepends the Hermes-managed Node to PATH, so # the build never runs on a too-old system Node — the cause of the opaque # "Build desktop app … exit code 1" failure (Vite crashes on old Node). check_node diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 194f87ae5131..1c6831015641 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -18,13 +18,13 @@ # if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi # # Env inputs (set before sourcing to override defaults): -# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH -# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install +# HERMES_NODE_MIN_VERSION (default: 26) — accepted on PATH +# HERMES_NODE_TARGET_MAJOR (default: 26) — installed when we install # HERMES_HOME (default: $HOME/.hermes) # ============================================================================ -HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}" -HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}" +HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-26}" +HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-26}" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" HERMES_NODE_AVAILABLE=false @@ -76,6 +76,93 @@ _nb_node_major() { [[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0 } +# The npm range the checkout's root package.json demands. Read from the +# manifest rather than duplicated here so the two can never drift; falls back +# to the current floor when the manifest is unreadable (vendored copy of this +# script, stripped install tree). +_nb_npm_range() { + if [ -n "${HERMES_NPM_TARGET_RANGE:-}" ]; then + printf '%s\n' "$HERMES_NPM_TARGET_RANGE" + return 0 + fi + local repo_root manifest range + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)" + manifest="$repo_root/package.json" + if [ -r "$manifest" ]; then + # sed, not node: this runs before a usable node is guaranteed. + range=$(sed -n '/"engines"/,/}/p' "$manifest" \ + | sed -n 's/.*"npm"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -1) + if [ -n "$range" ]; then + printf '%s\n' "$range" + return 0 + fi + fi + printf '>=12.0.0\n' +} + +# Upgrade the managed tree's bundled npm into the checkout's engines.npm range. +# +# The nodejs.org tarball ships whatever npm that Node major bundles — Node +# 26.5.1 bundles npm 11.17.0, one minor below our own `engines.npm` floor of +# >=12. With `engine-strict=true` in the repo .npmrc that is fatal, not a +# warning, so a brand-new install died at the first `npm ci` with EBADENGINE. +# The Python side recovers through hermes_cli/npm_engine.py; the installer path +# had no such rung, so provision the right npm here instead of reacting later. +# +# Three details are load-bearing, all mirroring upgrade_managed_npm(): +# - a temp cwd, so the checkout's own .npmrc (engine-strict, min-release-age) +# does not gate the very upgrade meant to satisfy it; +# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc; +# - an explicit --prefix at the managed tree, because +# _nb_configure_npm_prefix wrote prefix=~/.local into its etc/npmrc, and +# without the override this installs a second npm elsewhere while the +# managed tree stays stale. +# +# Best-effort: a failure here leaves a working Node with an old npm, which is +# strictly better than no Node at all, and npm_engine.py still covers the +# EBADENGINE that follows. +_nb_ensure_bundled_npm_range() { + local npm_bin="$HERMES_HOME/node/bin/npm" + [ -x "$npm_bin" ] || return 0 + + local range have want + range="$(_nb_npm_range)" + [ -n "$range" ] || return 0 + + # Skip the network round-trip when the bundled npm already satisfies the + # range. Only the ">=N" shape we actually author is checked; anything more + # exotic falls through to letting npm itself decide. + if [[ "$range" =~ ^\>=([0-9]+) ]]; then + want="${BASH_REMATCH[1]}" + have=$("$npm_bin" --version 2>/dev/null | cut -d. -f1) + if [[ "$have" =~ ^[0-9]+$ ]] && [ "$have" -ge "$want" ]; then + return 0 + fi + fi + + _nb_log "Upgrading bundled npm to satisfy $range..." + local tmp_cwd + tmp_cwd=$(mktemp -d) + if ( + cd "$tmp_cwd" || exit 1 + CI=1 npm_config_min_release_age=0 \ + "$npm_bin" install --global \ + --prefix "$HERMES_HOME/node" \ + "npm@$range" \ + --no-fund --no-audit --progress=false >/dev/null 2>&1 + ); then + rm -rf "$tmp_cwd" + _nb_ok "npm $("$npm_bin" --version 2>/dev/null) installed" + return 0 + fi + + rm -rf "$tmp_cwd" + _nb_warn "Could not upgrade bundled npm to $range — \`npm ci\` may fail with EBADENGINE." + _nb_warn "Fix manually: npm install -g --prefix \"$HERMES_HOME/node\" npm@\"$range\"" + return 1 +} + _nb_have_modern_node() { command -v node >/dev/null 2>&1 || return 1 [ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ] @@ -232,6 +319,9 @@ _nb_install_bundled_node() { _nb_have_modern_node || return 1 _nb_ok "Node $(node --version) installed to $HERMES_HOME/node/" + # The tarball's bundled npm is usually below the repo's engines.npm floor. + # Best-effort: an old npm still beats no Node. + _nb_ensure_bundled_npm_range || true return 0 } @@ -255,6 +345,24 @@ _nb_managed_tool_broken() { return 1 } +# The managed node runs but is below HERMES_NODE_TARGET_MAJOR — an old tree +# from a previous install (e.g. 22). Outdated heals the same way broken does, +# so existing users get upgraded on the next heal probe, not just on a full +# installer re-run. Mirrors _managed_node_tree_outdated() in +# hermes_constants.py. +_nb_managed_node_outdated() { + local probe ver major + for probe in "$HERMES_HOME/node/bin/node" "$HERMES_HOME/node/node"; do + [ -x "$probe" ] || continue + ver="$("$probe" --version 2>/dev/null)" || return 1 + major="${ver#v}"; major="${major%%.*}" + case "$major" in ''|*[!0-9]*) return 1 ;; esac + [ "$major" -lt "$HERMES_NODE_TARGET_MAJOR" ] && return 0 + return 1 + done + return 1 +} + _nb_managed_node_needs_heal() { local tool for tool in node npm npx; do @@ -262,7 +370,7 @@ _nb_managed_node_needs_heal() { return 0 fi done - return 1 + _nb_managed_node_outdated } # Redownload the pinned nodejs.org tarball when a managed tree exists but @@ -300,6 +408,13 @@ ensure_node() { if _nb_have_modern_node; then _nb_ok "Node $(node --version) found (Hermes-managed)" HERMES_NODE_AVAILABLE=true + # A tree from an older install still carries that Node major's + # bundled npm, and the upgrade in _nb_install_bundled_node is + # best-effort — one offline install leaves an at-target tree + # stranded below engines.npm forever, since heal only fires for a + # *broken* tree. Mirrors Update-ManagedNpm's reuse-path call in + # install.ps1. No-ops on a probe when the npm is already in range. + _nb_ensure_bundled_npm_range || true return 0 fi fi diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index f136504a8faf..e94c185233d2 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -213,6 +213,62 @@ def test_broken_managed_npm_returns_none_when_heal_fails(self, tmp_path, monkeyp assert find_node_executable("npm") is None + def test_outdated_managed_node_heals_to_target_major(self, tmp_path, monkeypatch): + """A healthy managed tree below the target major upgrades on next resolve.""" + profile_home = tmp_path / "profiles" / "assistant" + managed_bin = profile_home / "node" / "bin" + managed_bin.mkdir(parents=True) + old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n") + heal_called = {"value": False} + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) + + def _heal(): + heal_called["value"] = True + old_node.write_text("#!/bin/sh\necho 'v26.5.1'\nexit 0\n") + old_node.chmod(0o755) + return True + + monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal) + + resolved = hermes_constants.find_hermes_node_executable("node") + assert heal_called["value"] is True + assert resolved == str(old_node) + + def test_outdated_managed_node_survives_failed_heal(self, tmp_path, monkeypatch): + """Offline heal failure keeps serving the old tree — old Node beats no Node.""" + profile_home = tmp_path / "profiles" / "assistant" + managed_bin = profile_home / "node" / "bin" + managed_bin.mkdir(parents=True) + old_node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v22.20.0'\nexit 0\n") + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) + monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", lambda: False) + + assert hermes_constants.find_hermes_node_executable("node") == str(old_node) + + def test_target_major_managed_node_does_not_heal(self, tmp_path, monkeypatch): + """A tree already at the target major never triggers the heal.""" + profile_home = tmp_path / "profiles" / "assistant" + managed_bin = profile_home / "node" / "bin" + managed_bin.mkdir(parents=True) + node = self._stub(managed_bin, "node", "#!/bin/sh\necho 'v26.5.1'\nexit 0\n") + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setenv("PATH", "") + monkeypatch.setattr(hermes_constants, "_managed_node_heal_attempted", False) + + def _heal(): + raise AssertionError("heal must not run for an up-to-date tree") + + monkeypatch.setattr(hermes_constants, "heal_hermes_managed_node", _heal) + + assert hermes_constants.find_hermes_node_executable("node") == str(node) + class TestIsContainer: diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py new file mode 100644 index 000000000000..2a5d1b5528ba --- /dev/null +++ b/tests/test_managed_runtime_resolution.py @@ -0,0 +1,203 @@ +"""Guard: Hermes-owned subprocesses must not resolve managed runtimes by bare PATH. + +Hermes installs runtimes for itself — ``uv`` at ``$HERMES_HOME/bin/uv``, Node at +``$HERMES_HOME/node``. Neither directory is on the ambient PATH of an arbitrary +process, so ``shutil.which("uv")`` / ``shutil.which("node")`` in Hermes's own +code has two failure modes: + +* the managed runtime is invisible, so the caller reports "not installed" or + degrades to a slower tier on a machine that has exactly what it needed; and +* when a system copy also exists, the one Hermes does not own wins — which is + how a generated systemd unit or launchd plist can bake a system Node in and + keep resolving it across reboots. + +The fix per call site is one of ``find_node_executable()``, +``iter_hermes_node_dirs()``, ``resolve_uv()``, or ``ensure_uv()``. This test is +the ratchet that stops a new bare lookup from being added back. + +Reading source is normally banned (see AGENTS.md). It is the right tool here and +only here: the property under test is "no call site anywhere in the tree spells +it this way", which is a statement about the whole codebase rather than about +one function's behavior, and there is no runtime seam that can observe a lookup +that was never written. Every entry in the allow-list below names a call site +whose behavior is separately covered by a real test. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Runtimes Hermes provisions into HERMES_HOME and must therefore resolve +# through a managed-aware helper rather than PATH. +_MANAGED_COMMANDS = frozenset({"uv", "node", "npm", "npx"}) + +# Directories that are not Hermes-owned subprocess code: plugins ship their own +# resolution policy, tests assert against PATH deliberately, and skills/scripts +# run as standalone user-invoked programs. +_EXEMPT_DIRS = ( + "tests", + "plugins", + "skills", + "optional-skills", + "scripts", + "website", + "node_modules", + ".git", + ".venv", + "venv", + ".worktrees", +) + +# Call sites where a bare PATH lookup is the correct answer. Each entry is +# (path, command) -> why. Keep this list short and justified — the default +# answer for a new call site is a managed-aware helper, not a new exemption. +_ALLOWED: dict[tuple[str, str], str] = { + ("tools/env_probe.py", "uv"): ( + "Reports the environment the MODEL sees in the terminal tool. The model " + "can only run what is on that subshell's PATH, which local.py populates " + "with the managed dirs — so PATH is the correct question to ask here." + ), + ("hermes_cli/update_cmd.py", "uv"): ( + "Termux fallback: a pkg-installed uv lands on PATH but not in the " + "managed bin dir, and it is checked only after resolve_uv() misses." + ), + ("hermes_cli/update_cmd.py", "npm"): ( + "WSL diagnostic: deliberately inspects what PATH resolves so it can " + "warn that the only reachable npm is the Windows one." + ), + ("tools/lazy_deps.py", "uv"): ( + "Fallback after resolve_uv(), plus the except-branch for the " + "hermes_cli import guard." + ), + ("hermes_cli/gateway.py", "node"): ( + "Fallback rung of _append_node_dir_for_service(), after the managed " + "dirs from iter_hermes_node_dirs() are already appended." + ), + ("hermes_cli/main.py", "node"): ( + "_ensure_tui_node()'s idempotence gate: the question really is 'is " + "node already discoverable on PATH', before bootstrapping one." + ), + ("hermes_cli/main.py", "npm"): ( + "Same _ensure_tui_node() gate as node." + ), + ("tools/browser_tool.py", "npx"): ( + "agent-browser runs via `npx`, resolved against the extended browser " + "PATH that _merge_browser_path() already seeds with the managed dirs." + ), +} + + +def _iter_which_calls(tree: ast.AST): + """Yield (command, lineno) for every ``which("")`` call in *tree*. + + AST rather than a regex so prose in docstrings and comments that mentions + ``shutil.which("npm")`` is not mistaken for a call site. + """ + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not node.args: + continue + func = node.func + name = ( + func.attr + if isinstance(func, ast.Attribute) + else func.id if isinstance(func, ast.Name) else None + ) + if name != "which": + continue + first = node.args[0] + if isinstance(first, ast.Constant) and first.value in _MANAGED_COMMANDS: + yield first.value, node.lineno + + +def _source_files() -> list[Path]: + files: list[Path] = [] + for path in REPO_ROOT.rglob("*.py"): + rel = path.relative_to(REPO_ROOT) + if rel.parts and rel.parts[0] in _EXEMPT_DIRS: + continue + files.append(path) + return files + + +def _findings() -> list[tuple[str, str, int]]: + """Return (relpath, command, lineno) for every bare managed lookup.""" + found: list[tuple[str, str, int]] = [] + for path in _source_files(): + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if "which(" not in source: + continue + try: + tree = ast.parse(source) + except SyntaxError: + continue + rel = path.relative_to(REPO_ROOT).as_posix() + for command, lineno in _iter_which_calls(tree): + found.append((rel, command, lineno)) + return found + + +def test_no_unreviewed_bare_managed_runtime_lookups(): + """Every bare which() for a managed runtime is a reviewed exemption.""" + unexpected = [ + (rel, cmd, lineno) + for rel, cmd, lineno in _findings() + if (rel, cmd) not in _ALLOWED + ] + + assert not unexpected, ( + "Bare PATH lookup for a Hermes-managed runtime.\n\n" + + "\n".join(f" {rel}:{lineno} which({cmd!r})" for rel, cmd, lineno in unexpected) + + "\n\n$HERMES_HOME/bin (uv) and $HERMES_HOME/node are not on an " + "arbitrary process's PATH, so this resolves a system copy — or nothing " + "— on an install that has a managed one.\n" + "Use instead:\n" + " uv -> managed_uv.resolve_uv() (lookup) or ensure_uv() (may install)\n" + " node/npm -> hermes_constants.find_node_executable()\n" + " PATH env -> hermes_constants.iter_hermes_node_dirs()\n" + "If PATH really is the right question, add the site to _ALLOWED with a " + "reason." + ) + + +def test_allowlist_has_no_stale_entries(): + """A fixed call site must be dropped from the allow-list, not left to rot.""" + live = {(rel, cmd) for rel, cmd, _lineno in _findings()} + stale = sorted(set(_ALLOWED) - live) + + assert not stale, ( + "Allow-list entries no longer match any source line — the call site was " + "fixed or moved. Remove them:\n" + + "\n".join(f" {rel} ({cmd})" for rel, cmd in stale) + ) + + +@pytest.mark.parametrize( + "helper", + [ + "find_node_executable", + "find_hermes_node_executable", + "iter_hermes_node_dirs", + "with_hermes_node_path", + ], +) +def test_managed_node_helpers_exist(helper): + """The alternatives this guard points contributors at must be importable.""" + import hermes_constants + + assert callable(getattr(hermes_constants, helper)) + + +def test_managed_uv_helpers_exist(): + from hermes_cli.managed_uv import ensure_uv, managed_uv_path, resolve_uv + + assert callable(resolve_uv) + assert callable(ensure_uv) + assert managed_uv_path().parent.name == "bin" diff --git a/tools/env_probe.py b/tools/env_probe.py index d2031c5c3442..f656af3edcfd 100644 --- a/tools/env_probe.py +++ b/tools/env_probe.py @@ -203,6 +203,12 @@ def _build_probe_line() -> str: py3_has_pip = _has_pip_module("python3") if py3_ver else False pip_bound_to = _pip_python_version() py3_pep668 = _detect_pep668("python3") if py3_ver else False + # Bare which() is correct here, unlike Hermes's own uv call sites: this + # reports the environment *the model will see* in the terminal tool, and + # what the model can type is exactly what is on that subshell's PATH. + # local.py puts the Hermes-managed $HERMES_HOME/bin there, so a managed-only + # install answers yes — without that, claiming uv the model cannot invoke + # would be worse than claiming none. has_uv = shutil.which("uv") is not None # If python3 exists, has pip, has uv (or no PEP 668), and there's no diff --git a/tools/environments/local.py b/tools/environments/local.py index f3ad9d514b78..7422e875de96 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -1127,6 +1127,34 @@ def _prepend_hermes_bin_dir(existing_path: str) -> str: return sep.join([bin_dir, *entries]) +def _managed_runtime_path_entries() -> list[str]: + """Return existing Hermes-managed runtime dirs for the terminal subshell PATH. + + The terminal tool spawns a subshell whose PATH is the agent process's PATH + plus ``_SANE_PATH``. Neither carries the runtimes Hermes installs for + itself, so on a machine where Hermes provisioned its own toolchain a + command the agent runs resolves a system copy instead — or nothing at all: + + - ``$HERMES_HOME/node`` (+ ``/bin``) — installed to satisfy the desktop and + browser toolchain. ``tools/browser_tool.py`` already does this for its own + subprocesses; the agent's shell deserves the same. + - ``$HERMES_HOME/bin`` — the managed ``uv``. ``install.sh`` writes it there + and nothing has ever put that directory on PATH, so an install whose only + uv is the managed one looks uv-less to both the agent and the model. + + Resolved per call rather than cached in a module constant because + ``get_hermes_home()`` is profile-scoped and a managed tree can appear + mid-process (``heal_hermes_managed_node``, a first browser install). + """ + try: + from hermes_constants import get_hermes_home, iter_hermes_node_dirs + + candidates = [*iter_hermes_node_dirs(), get_hermes_home() / "bin"] + return [str(d) for d in candidates if d.is_dir()] + except Exception: + return [] + + def _append_missing_sane_path_entries(existing_path: str) -> str: """Return a normalised POSIX PATH with missing sane entries appended. @@ -1144,6 +1172,11 @@ def _append_missing_sane_path_entries(existing_path: str) -> str: - **Duplicates are collapsed** (first occurrence wins), so a caller PATH that already contains repeats is not propagated verbatim. + Hermes-managed runtime dirs are appended alongside the sane entries, not + prepended: a tool the user deliberately put on their own PATH still wins, + and the managed one only fills the gap where there would otherwise be + nothing. + For a well-formed PATH (no empties, no duplicates) the leading segment is byte-identical to the input and ordering is preserved; only the missing sane entries are appended. On Windows this is a no-op passthrough (the @@ -1153,6 +1186,9 @@ def _append_missing_sane_path_entries(existing_path: str) -> str: return existing_path sane_entries = [entry for entry in _SANE_PATH.split(":") if entry] + sane_entries.extend( + entry for entry in _managed_runtime_path_entries() if entry not in sane_entries + ) if not existing_path: return ":".join(sane_entries) diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 86332391043d..15992f7006b9 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -732,7 +732,18 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install uv_env["VIRTUAL_ENV"] = str(venv_root) # Tier 1: uv (preferred — fast, doesn't need pip in the venv) - uv_bin = shutil.which("uv") + # Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare + # which() misses the uv Hermes installed and falls through to the + # slower pip tier. Deliberately a lookup and not ensure_uv(): this runs + # mid-turn to install an optional dependency, and downloading uv + + # migrating the Python runtime as a side effect of that is a far bigger + # action than the caller asked for. Tier 2 pip covers the no-uv case. + try: + from hermes_cli.managed_uv import resolve_uv + + uv_bin = resolve_uv() or shutil.which("uv") + except Exception: + uv_bin = shutil.which("uv") if uv_bin: try: r = subprocess.run( diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 5f5d7fd04907..b3ca5de929f2 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -36,7 +36,7 @@ We value contributions in this order: | **Git** | With the `git-lfs` extension installed | | **Python 3.11–3.13** | uv will install it if missing | | **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | -| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | +| **Node.js 26+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | ### Install with the standard installer diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index 17d34883c9e6..2919d14acfb8 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -772,7 +772,7 @@ nix develop # Shell provides: # - Python 3.12 + uv (deps installed into .venv on first entry) -# - Node.js 22, ripgrep, git, openssh, ffmpeg on PATH +# - Node.js 26, ripgrep, git, openssh, ffmpeg on PATH # - Stamp-file optimization: re-entry is near-instant if deps haven't changed hermes setup diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index c072a01804ef..fd0c047d4837 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -472,7 +472,7 @@ docker run -d \ The official image is based on `debian:13.4` and includes: - Python 3.13 with dependencies synced from the lockfile via `uv sync --frozen --no-install-project` for the baked extras (`all`, `messaging`, Anthropic/Bedrock/Azure identity, Hindsight, Matrix), followed by a no-dependency editable install of Hermes itself. -- Node.js 22 + npm (for browser automation, WhatsApp bridge, TUI/Desktop bundles, and workspace build tooling) +- Node.js 26 + npm (for browser automation, WhatsApp bridge, TUI/Desktop bundles, and workspace build tooling) - Playwright with Chromium (`npx playwright install --with-deps chromium --only-shell`) - ripgrep, ffmpeg, git, and `xz-utils` as system utilities - **`docker-cli`** — so agents running inside the container can drive the host's Docker daemon (bind-mount `/var/run/docker.sock` to opt in) for `docker build`, `docker run`, container inspection, etc. diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 9562fba464e8..1424c427f041 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -89,7 +89,7 @@ This is the standalone command. The terminal-auth flow (`hermes acp --setup`) al What it does: -- Installs Node.js 22 LTS into `~/.hermes/node/` if missing +- Installs Node.js 26 into `~/.hermes/node/` if missing - `npm install -g agent-browser @askjo/camofox-browser` into that prefix (no sudo needed — `npm`'s `--prefix` points at the user-writable Hermes-managed Node) - Installs Playwright Chromium, or uses a detected system Chrome/Chromium when available diff --git a/website/docs/user-guide/windows-native.md b/website/docs/user-guide/windows-native.md index 91ca51045719..ac8d32770a04 100644 --- a/website/docs/user-guide/windows-native.md +++ b/website/docs/user-guide/windows-native.md @@ -56,7 +56,7 @@ On first launch (and on demand when a missing tool is detected), Hermes runs a s | Dependency | Why Hermes needs it | |---|---| | **PortableGit** | Provides `bash.exe` for the terminal tool and `git` for in-session clones. Provisioned at install time, not by `dep_ensure`. | -| **Node.js 22** | Required for the browser tool (`agent-browser`), the TUI's web bridge, and the WhatsApp bridge. | +| **Node.js 26** | Required for the browser tool (`agent-browser`), the TUI's web bridge, and the WhatsApp bridge. | | **ffmpeg** | Audio format conversion for TTS / voice messages. | | **ripgrep** | Fast file search — falls back to `grep` if unavailable. | | **npm packages** | `agent-browser`, Playwright Chromium, and any per-toolset Node deps are installed once at first browser-tool use. | @@ -69,7 +69,7 @@ Top-to-bottom, in order: 1. **Bootstraps `uv`** — Astral's fast Python manager. Installed to `%USERPROFILE%\.local\bin`. 2. **Installs Python 3.11** via `uv`. No existing Python needed. -3. **Installs Node.js 22** (winget if available, else a portable Node tarball unpacked under `%LOCALAPPDATA%\hermes\node`). Used for the browser tool and the WhatsApp bridge. +3. **Installs Node.js 26** (winget if available, else a portable Node tarball unpacked under `%LOCALAPPDATA%\hermes\node`). Used for the browser tool and the WhatsApp bridge. 4. **Installs portable Git** — if `git` is already on PATH the installer uses it; otherwise it downloads a trimmed, self-contained **PortableGit** (~45 MB, from the official `git-for-windows` release) to `%LOCALAPPDATA%\hermes\git`. No admin, no Windows installer registry, no interference with anything else on the box. 5. **Clones the repo** to `%LOCALAPPDATA%\hermes\hermes-agent` and creates a virtualenv inside it. 6. **Tiered `uv pip install`** — tries `.[all]` first, falls back to progressively smaller sets (`[messaging,dashboard,ext]` → `[messaging]` → `.`) if a `git+https` dep flakes on rate-limited GitHub. Prevents "single flake drops you to a bare install" failure mode. @@ -306,7 +306,7 @@ You set it in the current process only; close and reopen the shell, or set it at Chromium is auto-installed on first run. If the install failed (rate-limited GitHub, Playwright CDN hiccup), run `hermes doctor` — it will surface the missing Chromium and print the exact `npx playwright install chromium` command to fix it. **`agent-browser` fails with a weird Node version error.** -The installer provisions Node 22 at `%LOCALAPPDATA%\hermes\node` but your PATH may have an older system Node 18 first. Either move Hermes's node dir earlier on PATH, or delete the system install if you don't use Node elsewhere. +The installer provisions Node 26 at `%LOCALAPPDATA%\hermes\node` but your PATH may have an older system Node 18 first. Either move Hermes's node dir earlier on PATH, or delete the system install if you don't use Node elsewhere. **Chinese / Japanese / Arabic characters show as `?` in the CLI.** The UTF-8 stdio shim didn't activate. Check that `HERMES_DISABLE_WINDOWS_UTF8` is NOT set (`Get-ChildItem env:HERMES_DISABLE_WINDOWS_UTF8`). If it's empty and you still see `?`, the console host (very old `cmd.exe`) may not support UTF-8 at all — switch to Windows Terminal.