Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs-site-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/js-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
node-version: 26
cache: npm

- name: grab npm 12
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/js-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
26
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.11
33 changes: 18 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions agent/lsp/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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 # <HERMES_HOME>/lsp/
install_targets = [pkg] + list(extra_pkgs or [])
Expand Down
71 changes: 67 additions & 4 deletions apps/desktop/electron/backend-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
hermesManagedNodePathEntries,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
Expand All @@ -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')
Expand All @@ -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',
Expand Down Expand Up @@ -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'))
})

Expand Down Expand Up @@ -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)
Expand Down
33 changes: 31 additions & 2 deletions apps/desktop/electron/backend-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {}) {
Expand Down Expand Up @@ -126,6 +154,7 @@ export {
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
hermesManagedNodePathEntries,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
Expand Down
17 changes: 4 additions & 13 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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.
Expand Down
7 changes: 5 additions & 2 deletions hermes_cli/dep_ensure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading