WhatsApp bridge Node/npm runtime discovery on Windows and macOS
Cluster 2975 · gateway. Data as of
1 open item. Fix merged in #49254 on 19 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 9 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #49254. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 0 issues and 1 PR.
Members concern how the WhatsApp bridge (and, on Windows, the Desktop updater) locates Node/npm, covering preference for Hermes-managed Node over system PATH and detection of usable macOS runtimes such as bundled, Homebrew, or nvm installs. The set includes both bug reports and fix pull requests, several of which look like duplicates or near-duplicates.
Open issues0
Open PRs1
- PR#2983fix(whatsapp): detect usable macOS node runtimes
Fixes on record
-
#49254 fix(windows): prefer managed node for whatsapp and desktop
files:
apps/desktop/electron/main.cjs,gateway/platforms/whatsapp.py,hermes_cli/main.py,hermes_constants.py,tests/test_hermes_constants.pyShow the diff
-
apps/desktop/electron/main.cjs@@ -268,6 +268,23 @@ 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) +} + // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path // install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end // up with identical layouts and can share one install. @@ -1827,7 +1844,7 @@ async function applyUpdates(opts = {}) { env: { ...process.env, HERMES_HOME, - PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter) + PATH: pathWithHermesManagedNode(venvBin) }, detached: true, stdio: 'ignore', @@ -1871,7 +1888,7 @@ async function handOffWindowsBootstrapRecovery(reason) { env: { ...process.env, HERMES_HOME, - PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter) + PATH: pathWithHermesManagedNode(venvBin) }, detached: true, stdio: 'ignore', @@ -1952,13 +1969,11 @@ async function applyUpdatesPosixInApp() { } // Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s - // npm build can find them on a machine with no system Node. - const extraPath = [path.join(HERMES_HOME, 'node', 'bin'), path.join(updateRoot, 'venv', 'bin')] - .filter(Boolean) - .join(path.delimiter) + // npm build can find them on a machine with no system Node. Windows portable + // Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin. const env = { HERMES_HOME, - PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter) + PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } // `hermes update` reaps stale `hermes dashboard` backends (a code update -
gateway/platforms/whatsapp.py@@ -19,7 +19,6 @@ import logging import os import platform -import shutil import signal import subprocess @@ -27,7 +26,11 @@ from pathlib import Path from typing import Dict, Optional, Any -from hermes_constants import get_hermes_dir +from hermes_constants import ( + find_node_executable, + get_hermes_dir, + with_hermes_node_path, +) logger = logging.getLogger(__name__) @@ -212,10 +215,9 @@ def check_whatsapp_requirements() -> bool: WhatsApp requires a Node.js bridge for most implementations. """ - # Check for Node.js. Resolve via shutil.which so we respect PATHEXT - # (node.exe vs node) and get a meaningful "not installed" signal - # instead of spawning a cmd flash on Windows. - _node = shutil.which("node") + # Prefer Hermes-managed Node/npm so Windows installs are not broken by a + # bad or elevation-triggering system Node on PATH. + _node = find_node_executable("node") if not _node: return False try: @@ -404,10 +406,9 @@ async def connect(self) -> bool: _deps_fresh = False if not _deps_fresh: print(f"[{self.name}] Installing WhatsApp bridge dependencies...") - # Resolve npm path so Windows can execute the .cmd shim. - # shutil.which honours PATHEXT; on POSIX it returns the - # plain executable path. - _npm_bin = shutil.which("npm") or "npm" + # Resolve npm path so Windows uses npm.cmd from the + # Hermes-managed portable Node before falling back to PATH. + _npm_bin = find_node_executable("npm") or "npm" try: # Read timeout from environment variable, default to 300 seconds (5 minutes) # to accommodate slower systems like Unraid NAS @@ -418,6 +419,7 @@ async def connect(self) -> bool: capture_output=True, text=True, timeout=npm_install_timeout, + env=with_hermes_node_path(), ) if install_result.returncode != 0: print(f"[{self.name}] npm install failed: {install_result.stderr}") @@ -490,7 +492,8 @@ async def connect(self) -> bool: # Build bridge subprocess environment. # Pass WHATSAPP_REPLY_PREFIX from config.yaml so the Node bridge # can use it without the user needing to set a separate env var. - bridge_env = os.environ.copy() + # with_hermes_node_path() copies os.environ when called with no arg. + bridge_env = with_hermes_node_path() if self._reply_prefix is not None: bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix # Pass the profile-aware cache directories so the bridge writes @@ -508,7 +511,7 @@ async def connect(self) -> bool: self._bridge_process = subprocess.Popen( [ - "node", + find_node_executable("node") or "node", str(bridge_path), "--port", str(self._bridge_port), "--session", str(self._session_path), -
hermes_cli/main.py@@ -2363,6 +2363,7 @@ def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" _require_tty("whatsapp") from hermes_cli.config import get_env_value, save_env_value + from hermes_constants import find_node_executable, with_hermes_node_path print() print("⚕ WhatsApp Setup") @@ -2477,7 +2478,7 @@ def cmd_whatsapp(args): print( "\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..." ) - npm = shutil.which("npm") + npm = find_node_executable("npm") if not npm: print(" ✗ npm not found on PATH — install Node.js first") return @@ -2490,6 +2491,7 @@ def cmd_whatsapp(args): text=True, encoding="utf-8", errors="replace", + env=with_hermes_node_path(), ) except KeyboardInterrupt: print("\n ✗ Install cancelled") @@ -2546,8 +2548,15 @@ def cmd_whatsapp(args): try: subprocess.run( - ["node", str(bridge_script), "--pair-only", "--session", str(session_dir)], + [ + find_node_executable("node") or "node", + str(bridge_script), + "--pair-only", + "--session", + str(session_dir), + ], cwd=str(bridge_dir), + env=with_hermes_node_path(), ) except KeyboardInterrupt: pass @@ -4535,6 +4544,7 @@ def _run_with_idle_timeout( *, idle_timeout_seconds: int = 180, indent: str = " ", + env: dict[str, str] | None = None, ) -> subprocess.CompletedProcess: """Run a subprocess that streams output, with an idle-output timeout. @@ -4569,6 +4579,7 @@ def _run_with_idle_timeout( encoding="utf-8", errors="replace", bufsize=1, + env=env, ) except OSError as exc: # E.g. npm not on PATH between the which() check and now. @@ -4760,12 +4771,15 @@ def _say(text: str) -> None: encoding = getattr(sys.stdout, "encoding", None) or "ascii" print(text.encode(encoding, errors="replace").decode(encoding, errors="replace")) - npm = shutil.which("npm") + from hermes_constants import find_node_executable, with_hermes_node_path + + npm = find_node_executable("npm") if not npm: if fatal: _say("Web UI frontend not built and npm is not available.") _say("Install Node.js, then run: cd web && npm install && npm run build") return not fatal + build_env = with_hermes_node_path() _say("→ Building web UI...") def _relay(result: "subprocess.CompletedProcess") -> None: @@ -4797,6 +4811,7 @@ def _relay(result: "subprocess.CompletedProcess") -> None: npm, npm_cwd, extra_args=(*npm_workspace_args, "--silent"), + env=build_env, ) if r1.returncode != 0: _say( @@ -4812,13 +4827,13 @@ def _relay(result: "subprocess.CompletedProcess") -> None: # users react by rebooting, which leaves the editable install in a # half-state. Streaming + idle-kill makes failures observable AND # recoverable (the stale-dist fallback below handles the kill path). - r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir) + r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # Retry once after a short delay — covers boot-time races on Windows # (antivirus scanning Node.js binaries, npm cache not ready, transient # I/O when launched via Scheduled Task at logon). See issue #23817. _time.sleep(3) - r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir) + r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=build_env) if r2.returncode != 0: # _run_with_idle_timeout merges stderr into stdout; older callers @@ -5197,7 +5212,9 @@ def _redownload_electron_dist( installer = electron_dir / "install.js" if not installer.is_file(): return False - node = shutil.which("node") + from hermes_constants import find_node_executable, with_hermes_node_path + + node = find_node_executable("node") if not node: return False @@ -5208,7 +5225,7 @@ def _redownload_electron_dist( except OSError: pass - dl_env = dict(env) + dl_env = with_hermes_node_path(env) if mirror: dl_env["ELECTRON_MIRROR"] = mirror try: @@ -5388,7 +5405,10 @@ def cmd_gui(args: argparse.Namespace): except Exception: pass - env = os.environ.copy() + from hermes_constants import find_node_executable, with_hermes_node_path + + # with_hermes_node_path() copies os.environ when called with no arg. + env = with_hermes_node_path() if getattr(args, "fake_boot", False): env["HERMES_DESKTOP_BOOT_FAKE"] = "1" if getattr(args, "ignore_existing", False): @@ -5405,7 +5425,7 @@ def cmd_gui(args: argparse.Namespace): packaged_executable = _desktop_packaged_executable(desktop_dir) if source_mode or not skip_build: - npm = shutil.which("npm") + npm = find_node_executable("npm") if not npm: print("Desktop GUI requires Node.js/npm, but npm was not found on PATH.") print("Install Node.js, then run: hermes gui") @@ -7637,7 +7657,9 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: def _update_node_dependencies() -> None: - npm = shutil.which("npm") + from hermes_constants import find_node_executable, with_hermes_node_path + + npm = find_node_executable("npm") if not npm: return @@ -7654,7 +7676,7 @@ def _update_node_dependencies() -> None: print("→ Updating Node.js dependencies...") extra_args = ["--no-fund", "--no-audit", "--progress=false"] - nixos_env = _nixos_build_env() + nixos_env = with_hermes_node_path(_nixos_build_env()) # Step 1: root install (no workspace recursion). root_args = [*extra_args, "--workspaces=false"] @@ -9022,7 +9044,9 @@ def _cmd_update_impl(args, gateway_mode: bool): # Electron build by ``hermes update``. desktop_dir = PROJECT_ROOT / "apps" / "desktop" has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir) - if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app: + from hermes_constants import find_node_executable + + if (desktop_dir / "package.json").exists() and find_node_executable("npm") and has_desktop_app: print("→ Checking if desktop app needs rebuilding...") _desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"] # Stream the build output live (long Electron builds otherwise -
hermes_constants.py@@ -5,6 +5,7 @@ """ import os +import shutil import sys import sysconfig from contextvars import ContextVar, Token @@ -242,6 +243,75 @@ def get_hermes_dir(new_subpath: str, old_name: str) -> Path: return home / new_subpath +def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]: + """Return Hermes-managed Node.js directories in preferred lookup order. + + Windows installs from ``scripts/install.ps1`` unpack portable Node directly + into ``%LOCALAPPDATA%\\hermes\\node``. POSIX installs use + ``$HERMES_HOME/node/bin``. Include both shapes on every platform so mixed + or migrated installs still work. + """ + root = home or get_hermes_home() + 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. + if sys.platform == "win32": + return dirs + [bin_dir] + return [bin_dir] + dirs + + +def _candidate_node_command_names(command: str) -> list[str]: + base = Path(command).name + if sys.platform != "win32" or "." in base: + return [base] + if base.lower() == "npm": + # Prefer npm.cmd. PowerShell may block npm.ps1 by execution policy, and + # CreateProcess cannot launch a bare .ps1 the way it can launch .cmd. + return ["npm.cmd", "npm.exe", "npm"] + if base.lower() == "npx": + return ["npx.cmd", "npx.exe", "npx"] + if base.lower() == "node": + return ["node.exe", "node"] + return [f"{base}.cmd", f"{base}.exe", base] + + +def find_hermes_node_executable(command: str) -> str | None: + """Return a Hermes-managed Node/npm executable path, if installed.""" + names = _candidate_node_command_names(command) + for directory in iter_hermes_node_dirs(): + for name in names: + candidate = directory / name + if candidate.is_file() and ( + sys.platform == "win32" or os.access(candidate, os.X_OK) + ): + return str(candidate) + return None + + +def find_node_executable(command: str) -> str | None: + """Resolve a Node.js command, preferring Hermes-managed installs. + + This is for Hermes-owned subprocesses that should not be broken by a bad, + missing, or elevation-triggering system Node/npm on PATH. + """ + return find_hermes_node_executable(command) or shutil.which(command) + + +def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]: + """Return *env* with Hermes-managed Node directories prepended to PATH.""" + merged = dict(os.environ if env is None else env) + existing = merged.get("PATH", "") + parts = [p for p in existing.split(os.pathsep) if p] + managed = [str(path) for path in iter_hermes_node_dirs() if path.is_dir()] + for entry in reversed(managed): + if entry not in parts: + parts.insert(0, entry) + merged["PATH"] = os.pathsep.join(parts) + return merged + + def display_hermes_home() -> str: """Return a user-friendly display string for the current HERMES_HOME. -
tests/test_hermes_constants.py@@ -8,11 +8,14 @@ import hermes_constants from hermes_constants import ( VALID_REASONING_EFFORTS, + find_hermes_node_executable, get_default_hermes_root, get_hermes_home, + iter_hermes_node_dirs, is_container, parse_reasoning_effort, secure_parent_dir, + with_hermes_node_path, ) @@ -105,6 +108,45 @@ def test_windows_fallback_uses_localappdata(self, tmp_path, monkeypatch): assert get_hermes_home() == local_appdata / "hermes" +class TestHermesManagedNode: + def test_windows_node_dir_prefers_portable_root(self, tmp_path, monkeypatch): + home = tmp_path / "hermes" + node_dir = home / "node" + bin_dir = node_dir / "bin" + node_dir.mkdir(parents=True) + bin_dir.mkdir() + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setenv("HERMES_HOME", str(home)) + + assert iter_hermes_node_dirs() == [node_dir, bin_dir] + + def test_windows_finds_npm_cmd_before_path(self, tmp_path, monkeypatch): + home = tmp_path / "hermes" + node_dir = home / "node" + node_dir.mkdir(parents=True) + npm_cmd = node_dir / "npm.cmd" + npm_cmd.write_text("@echo off\n") + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setenv("HERMES_HOME", str(home)) + + assert find_hermes_node_executable("npm") == str(npm_cmd) + + def test_with_hermes_node_path_prepends_existing_managed_dirs(self, tmp_path, monkeypatch): + home = tmp_path / "hermes" + node_dir = home / "node" + bin_dir = node_dir / "bin" + node_dir.mkdir(parents=True) + bin_dir.mkdir() + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setenv("HERMES_HOME", str(home)) + + env = with_hermes_node_path({"PATH": "system-node"}) + parts = env["PATH"].split(os.pathsep) + + assert parts[:2] == [str(node_dir), str(bin_dir)] + assert parts[-1] == "system-node" + + class TestIsContainer: """Tests for is_container() — Docker/Podman detection.""" @@ -351,4 +393,3 @@ def test_symlink_resolved(self, tmp_path, monkeypatch): secure_parent_dir(link_target) assert len(called_with) == 1 assert called_with[0] == (str(real_dir), 0o700) -
-
4 PRs closed without merging
From the close comments: done elsewhere 3 · superseded by another PR 1
-
#2977 fix(whatsapp): add macOS Node.js runtime discovery for bundled/Homebrew/nvm installs · superseded by another PR →
7a7b56d,#2983 -
#49261 fix(whatsapp): prefer Hermes-managed Node over system PATH for the bridge · done elsewhere →
#49254 -
#49346 fix(whatsapp): prefer Hermes-managed Node/npm over system PATH (#49242) · done elsewhere →
#49254,#49242 -
#83695 fix(whatsapp): detect usable macOS node runtimes · done elsewhere →
51bcd46,c0106e5
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.21: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #49254
- Closed issues
- 3
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 19 Jun 2026