# NousResearch/hermes-agent · WhatsApp bridge Node/npm runtime discovery on Windows and macOS

## Open now

- 0 issues, 1 PR open on GitHub.
- PR #2983: fix(whatsapp): detect usable macOS node runtimes

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.

## TL;DR

- 9 member(s): 0 open issue(s), 3 closed issue(s), 1 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #49254. Verify it addresses your case.
- **Recommended:** read PR #49254 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2975: \[Bug\]: WhatsApp bridge only checks  on PATH and misses usable macOS runtimes
- #2976: \[Bug\]: WhatsApp bridge only checks node on PATH and misses usable macOS runtimes
- #49242: \[Bug\]: Windows: WhatsApp gateway and Desktop updater should prefer Hermes-managed Node/npm over system PATH

## Prior & competing PRs

- **#49254: fix(windows): prefer managed node for whatsapp and desktop**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/electron/main.cjs`, `gateway/platforms/whatsapp.py`, `hermes_cli/main.py`, `hermes_constants.py`, `tests/test_hermes_constants.py`
  - fix hunks:
  - `apps/desktop/electron/main.cjs`

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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',
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -19,7 +19,6 @@
     import logging
     import os
     import platform
    -import shutil
     import signal
     import subprocess
     
    ```

    ```diff
    @@ -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__)
     
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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}")
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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):
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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"]
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -5,6 +5,7 @@
     """
     
     import os
    +import shutil
     import sys
     import sysconfig
     from contextvars import ContextVar, Token
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
     )
     
     
    ```

    ```diff
    @@ -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."""
     
    ```

    ```diff
    @@ -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)
    -
    ```
- **#2977: fix(whatsapp): add macOS Node.js runtime discovery for bundled/Homebrew/nvm installs**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `7a7b56d49`, `#2983`
- **#2983: fix(whatsapp): detect usable macOS node runtimes**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#2976`, `#2975`
  - files: `gateway/platforms/whatsapp.py`, `tests/gateway/test_whatsapp_connect.py`
- **#49261: fix(whatsapp): prefer Hermes-managed Node over system PATH for the bridge**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#49254`
- **#49346: fix(whatsapp): prefer Hermes-managed Node/npm over system PATH (#49242)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#49254`, `#49242`
- **#83695: fix(whatsapp): detect usable macOS node runtimes**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `51bcd46fb4e30736f2105d970418bd1e128a754b`, `c0106e50e7ecedb3ce34e785d949725dc4e0e457`

## Fix status

- Merged to main in PR #49254 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-19`.
- Subsystem: `gateway`
