Node/npm symlink conflicts with user version managers

NousResearch/hermes-agent · cluster 18158 · agent

Data as of

5 open items. Fix merged in #34835. Check it covers your case.

The short answer

2open issues
3open PRs
21items grouped
Known?
Yes. 21 related items grouped here, 6 issues already closed.
Fix event
A fix was merged to main in PR #34835. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #34835. That is separate from the fix event.
Still open
2 issues and 3 PRs.

Issues and fixes around the installer creating or leaving behind node/npm/npx symlinks that shadow or hijack user-managed Node installations from nvm, fnm, mise, asdf, and Homebrew. Related concerns include tool discovery failures when Hermes is launched non-interactively and PATH differs from the user's shell environment.

Open issues2

  • Issue#18158hermes doctor / browser tool can't find Node when launched non-interactively (mise/asdf/nvm shims, Homebrew)
  • Issue#64538Browser tool cannot discover agent-browser installed by fnm on Windows

Open PRs3

  • PR#45282fix(install): skip node/npm/npx symlinks when system versions exist
  • PR#64545fix(browser): discover fnm installs on Windows
  • PR#111921fix(installer): skip node/npm/npx symlinks for user installs

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #34835 fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall

    merged to main

    files: hermes_cli/uninstall.py, pyproject.toml, tests/hermes_cli/test_uninstall_node_symlinks.py

    Show the diff
    • hermes_cli/uninstall.py
      @@ -117,6 +117,49 @@ def remove_wrapper_script():
           return removed
       
       
      +def remove_node_symlinks(hermes_home: Path) -> list:
      +    """Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
      +
      +    The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
      +    creates::
      +
      +        ~/.local/bin/node -> $HERMES_HOME/node/bin/node
      +        ~/.local/bin/npm  -> $HERMES_HOME/node/bin/npm
      +        ~/.local/bin/npx  -> $HERMES_HOME/node/bin/npx
      +
      +    and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
      +    manager such as nvm.  Symmetrically remove them on uninstall, but *only*
      +    when the link still resolves into this Hermes home's ``node`` directory.
      +    A link the user has since repointed at nvm (or anything else outside
      +    Hermes) is left untouched so we never break unrelated tooling.
      +    """
      +    node_dir = (hermes_home / "node").resolve()
      +    removed = []
      +
      +    for name in ("node", "npm", "npx"):
      +        link = Path.home() / ".local" / "bin" / name
      +        try:
      +            # Only act on symlinks — never delete a real binary the user put here.
      +            if not link.is_symlink():
      +                continue
      +
      +            # Resolve the link target and confirm it points into our node dir.
      +            # os.readlink + manual join handles broken (dangling) links too;
      +            # Path.resolve() on a dangling link still returns the target path.
      +            target = Path(os.readlink(link))
      +            if not target.is_absolute():
      +                target = (link.parent / target)
      +            target = target.resolve()
      +
      +            if target == node_dir or node_dir in target.parents:
      +                link.unlink()
      +                removed.append(link)
      +        except Exception as e:
      +            log_warn(f"Could not remove {link}: {e}")
      +
      +    return removed
      +
      +
       def uninstall_gateway_service():
           """Stop and uninstall the gateway service (systemd, launchd, Windows
           Scheduled Task / Startup folder) and kill any standalone gateway processes.
      @@ -594,6 +637,17 @@ def run_uninstall(args):
                   log_success(f"Removed {wrapper}")
           else:
               log_info("No wrapper script found")
      +
      +    # 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin
      +    #     (only when they still point into this Hermes home's node dir, so we
      +    #     never clobber an existing nvm / user-managed Node).
      +    log_info("Removing Hermes-managed node/npm/npx symlinks...")
      +    removed_node_links = remove_node_symlinks(hermes_home)
      +    if removed_node_links:
      +        for link in removed_node_links:
      +            log_success(f"Removed {link}")
      +    else:
      +        log_info("No Hermes-managed node/npm/npx symlinks found")
           
           # 4. Remove installation directory (code)
           log_info("Removing installation directory...")
    • pyproject.toml
      @@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"]
       modal = ["modal==1.3.4"]
       daytona = ["daytona==0.155.0"]
       hindsight = ["hindsight-client==0.6.1"]
      -dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
      +dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"]
       messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
       cron = []  # croniter is now a core dependency; this extra kept for back-compat
       slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
    • tests/hermes_cli/test_uninstall_node_symlinks.py
      @@ -0,0 +1,132 @@
      +"""Tests for hermes_cli.uninstall.remove_node_symlinks.
      +
      +Regression for #34536: the POSIX installer drops node/npm/npx symlinks in
      +~/.local/bin pointing into $HERMES_HOME/node and prepends ~/.local/bin to
      +PATH, shadowing an existing nvm. Uninstall must remove those symlinks, but
      +only when they still resolve into the Hermes-managed node dir.
      +"""
      +
      +import os
      +from pathlib import Path
      +
      +import pytest
      +
      +import hermes_cli.uninstall as uninstall
      +
      +
      [redacted:email]
      +def fake_home(tmp_path, monkeypatch):
      +    """Redirect Path.home() at the home both the installer-symlink target and
      +    the ~/.local/bin links live under the same temp dir."""
      +    home = tmp_path / "home"
      +    home.mkdir()
      +    monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
      +    (home / ".local" / "bin").mkdir(parents=True)
      +    return home
      +
      +
      +def _make_hermes_node(hermes_home: Path) -> Path:
      +    """Create a fake $HERMES_HOME/node/bin/{node,npm,npx} tree."""
      +    node_bin = hermes_home / "node" / "bin"
      +    node_bin.mkdir(parents=True)
      +    for name in ("node", "npm", "npx"):
      +        (node_bin / name).write_text("#!/bin/sh\n")
      +        (node_bin / name).chmod(0o755)
      +    return node_bin
      +
      +
      +def test_removes_symlinks_pointing_into_hermes_node(fake_home):
      +    hermes_home = fake_home / ".hermes"
      +    node_bin = _make_hermes_node(hermes_home)
      +    local_bin = fake_home / ".local" / "bin"
      +
      +    for name in ("node", "npm", "npx"):
      +        (local_bin / name).symlink_to(node_bin / name)
      +
      +    removed = uninstall.remove_node_symlinks(hermes_home)
      +
      +    assert sorted(p.name for p in removed) == ["node", "npm", "npx"]
      +    for name in ("node", "npm", "npx"):
      +        assert not (local_bin / name).exists()
      +        assert not (local_bin / name).is_symlink()
      +
      +
      +def test_leaves_unrelated_symlinks_untouched(fake_home):
      +    """A node symlink the user repointed at nvm must survive uninstall."""
      +    hermes_home = fake_home / ".hermes"
      +    _make_hermes_node(hermes_home)
      +    local_bin = fake_home / ".local" / "bin"
      +
      +    # Simulate nvm's node living elsewhere; user's ~/.local/bin/node -> nvm.
      +    nvm_bin = fake_home / ".nvm" / "versions" / "node" / "v20.0.0" / "bin"
      +    nvm_bin.mkdir(parents=True)
      +    (nvm_bin / "node").write_text("#!/bin/sh\n")
      +    (local_bin / "node").symlink_to(nvm_bin / "node")
      +
      +    removed = uninstall.remove_node_symlinks(hermes_home)
      +
      +    assert removed == []
      +    assert (local_bin / "node").is_symlink()
      +    assert (local_bin / "node").resolve() == (nvm_bin / "node").resolve()
      +
      +
      +def test_leaves_real_binaries_untouched(fake_home):
      +    """A real (non-symlink) binary in ~/.local/bin is never deleted."""
      +    hermes_home = fake_home / ".hermes"
      +    _make_hermes_node(hermes_home)
      +    local_bin = fake_home / ".local" / "bin"
      +
      +    real_node = local_bin / "node"
      +    real_node.write_text("#!/bin/sh\necho real\n")
      +    real_node.chmod(0o755)
      +
      +    removed = uninstall.remove_node_symlinks(hermes_home)
      +
      +    assert removed == []
      +    assert real_node.exists()
      +    assert not real_node.is_symlink()
      +
      +
      +def test_handles_missing_local_bin(fake_home):
      +    """No symlinks present -> no-op, no error."""
      +    hermes_home = fake_home / ".hermes"
      +    _make_hermes_node(hermes_home)
      +
      +    assert uninstall.remove_node_symlinks(hermes_home) == []
      +
      +
      +def test_removes_dangling_symlink_into_hermes_node(fake_home):
      +    """A link into the Hermes node dir is removed even if the target file is
      +    already gone (dangling) \u2014 the link still shadows PATH."""
      +    hermes_home = fake_home / ".hermes"
      +    node_bin = hermes_home / "node" / "bin"
      +    node_bin.mkdir(parents=True)
      +    local_bin = fake_home / ".local" / "bin"
      +
      +    # Create the symlink, then delete the target so it dangles.
      +    (local_bin / "node").symlink_to(node_bin / "node")
      +    assert (local_bin / "node").is_symlink()
      +
      +    removed = uninstall.remove_node_symlinks(hermes_home)
      +
      +    assert [p.name for p in removed] == ["node"]
      +    assert not (local_bin / "node").is_symlink()
      +
      +
      +def test_only_some_links_present(fake_home):
      +    """Removes the Hermes links that exist; ignores the ones that don't."""
      +    hermes_home = fake_home / ".hermes"
      +    node_bin = _make_hermes_node(hermes_home)
      +    local_bin = fake_home / ".local" / "bin"
      +
      +    # Only npm and npx are Hermes-managed; node is a real user binary.
      +    (local_bin / "npm").symlink_to(node_bin / "npm")
      +    (local_bin / "npx").symlink_to(node_bin / "npx")
      +    (local_bin / "node").write_text("#!/bin/sh\n")
      +
      +    removed = uninstall.remove_node_symlinks(hermes_home)
      +
      +    assert sorted(p.name for p in removed) == ["npm", "npx"]
      +    assert (local_bin / "node").exists()
      +    assert not (local_bin / "npm").is_symlink()
      +    assert not (local_bin / "npx").is_symlink()
    • uv.lock
      @@ -1629,6 +1629,7 @@ all = [
           { name = "pytest-timeout" },
           { name = "pywinpty", marker = "sys_platform == 'win32'" },
           { name = "ruff" },
      +    { name = "setuptools" },
           { name = "simple-term-menu" },
           { name = "ty" },
           { name = "uvicorn", extra = ["standard"] },
      @@ -1659,6 +1660,7 @@ dev = [
           { name = "pytest-asyncio" },
           { name = "pytest-timeout" },
           { name = "ruff" },
      +    { name = "setuptools" },
           { name = "ty" },
       ]
       dingtalk = [
      @@ -1864,6 +1866,7 @@ requires-dist = [
           { name = "rich", specifier = "==14.3.3" },
           { name = "ruamel-yaml", specifier = "==0.18.17" },
           { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
      +    { name = "setuptools", marker = "extra == 'dev'", specifier = "==82.0.1" },
           { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
           { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" },
           { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" },
  • #38889 fix(installer): symlink bundled node/npm into command bin dir for FHS root installs

    merged to main

    files: hermes_cli/uninstall.py, scripts/install.sh, scripts/lib/node-bootstrap.sh, tests/hermes_cli/test_uninstall_node_symlinks.py

    Show the diff
    • hermes_cli/uninstall.py
      @@ -9,6 +9,7 @@
       import os
       import shutil
       import subprocess
      +import sys
       from pathlib import Path
       
       from hermes_constants import get_hermes_home
      @@ -117,45 +118,60 @@ def remove_wrapper_script():
           return removed
       
       
      -def remove_node_symlinks(hermes_home: Path) -> list:
      -    """Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
      +def _node_symlink_candidate_dirs() -> "list[Path]":
      +    """Directories where the installer may have placed node/npm/npx symlinks."""
      +    dirs: list[Path] = [Path.home() / ".local" / "bin"]
      +    # Root FHS installs put links in /usr/local/bin.
      +    if sys.platform == "linux":
      +        dirs.append(Path("/usr/local/bin"))
      +    # Termux installs put links in $PREFIX/bin.
      +    prefix = os.environ.get("PREFIX", "")
      +    if prefix and "com.termux" in prefix:
      +        dirs.append(Path(prefix) / "bin")
      +    return dirs
       
      -    The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
      -    creates::
       
      -        ~/.local/bin/node -> $HERMES_HOME/node/bin/node
      -        ~/.local/bin/npm  -> $HERMES_HOME/node/bin/npm
      -        ~/.local/bin/npx  -> $HERMES_HOME/node/bin/npx
      +def remove_node_symlinks(hermes_home: Path) -> list:
      +    """Remove the node/npm/npx symlinks the installer placed on PATH.
       
      -    and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
      -    manager such as nvm.  Symmetrically remove them on uninstall, but *only*
      -    when the link still resolves into this Hermes home's ``node`` directory.
      -    A link the user has since repointed at nvm (or anything else outside
      -    Hermes) is left untouched so we never break unrelated tooling.
      +    The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
      +    symlinks node/npm/npx into the same directory as the ``hermes`` command:
      +
      +    - ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)
      +    - ``$PREFIX/bin/`` on Termux
      +    - ``~/.local/bin/`` otherwise (the common non-root case)
      +
      +    We check all candidate directories so that uninstall works regardless of
      +    how the install was done (e.g. a root FHS install that placed links in
      +    ``/usr/local/bin``, or an older install that used ``~/.local/bin`` before
      +    the FHS fix).  Only symlinks that resolve into this Hermes home's ``node``
      +    directory are removed — links the user has repointed elsewhere (nvm, fnm,
      +    etc.) are left untouched.
           """
           node_dir = (hermes_home / "node").resolve()
           removed = []
       
           for name in ("node", "npm", "npx"):
      -        link = Path.home() / ".local" / "bin" / name
      -        try:
      -            # Only act on symlinks — never delete a real binary the user put here.
      -            if not link.is_symlink():
      -                continue
      -
      -            # Resolve the link target and confirm it points into our node dir.
      -            # os.readlink + manual join handles broken (dangling) links too;
      -            # Path.resolve() on a dangling link still returns the target path.
      -            target = Path(os.readlink(link))
      -            if not target.is_absolute():
      -                target = (link.parent / target)
      -            target = target.resolve()
      -
      -            if target == node_dir or node_dir in target.parents:
      -                link.unlink()
      -                removed.append(link)
      -        except Exception as e:
      -            log_warn(f"Could not remove {link}: {e}")
      +        for bin_dir in _node_symlink_candidate_dirs():
      +            link = bin_dir / name
      +            try:
      +                # Only act on symlinks — never delete a real binary the user put here.
      +                if not link.is_symlink():
      +                    continue
      +
      +                # Resolve the link target and confirm it points into our node dir.
      +                # os.readlink + manual join handles broken (dangling) links too;
      +                # Path.resolve() on a dangling link still returns the target path.
      +                target = Path(os.readlink(link))
      +                if not target.is_absolute():
      +                    target = (link.parent / target)
      +                target = target.resolve()
      +
      +                if target == node_dir or node_dir in target.parents:
      +                    link.unlink()
      +                    removed.append(link)
      +            except Exception as e:
      +                log_warn(f"Could not remove {link}: {e}")
       
           return removed
       
    • scripts/install.sh
      @@ -836,16 +836,20 @@ install_node() {
               return 0
           fi
       
      -    # Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/
      +    # Place into ~/.hermes/node/ and symlink binaries into the same bin dir
      +    # the hermes command uses (get_command_link_dir): /usr/local/bin for root
      +    # FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.
           rm -rf "$HERMES_HOME/node"
           mkdir -p "$HERMES_HOME"
           mv "$extracted_dir" "$HERMES_HOME/node"
           rm -rf "$tmp_dir"
       
      -    mkdir -p "$HOME/.local/bin"
      -    ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
      -    ln -sf "$HERMES_HOME/node/bin/npm"  "$HOME/.local/bin/npm"
      -    ln -sf "$HERMES_HOME/node/bin/npx"  "$HOME/.local/bin/npx"
      +    local node_link_dir
      +    node_link_dir="$(get_command_link_dir)"
      +    mkdir -p "$node_link_dir"
      +    ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
      +    ln -sf "$HERMES_HOME/node/bin/npm"  "$node_link_dir/npm"
      +    ln -sf "$HERMES_HOME/node/bin/npx"  "$node_link_dir/npx"
       
           export PATH="$HERMES_HOME/node/bin:$PATH"
       
    • scripts/lib/node-bootstrap.sh
      @@ -44,6 +44,19 @@ _nb_is_termux() {
           [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
       }
       
      +# Where to symlink node/npm/npx so they land on PATH.
      +# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,
      +# Termux → $PREFIX/bin, otherwise ~/.local/bin.
      +_nb_get_link_dir() {
      +    if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
      +        echo "$PREFIX/bin"
      +    elif [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
      +        echo "/usr/local/bin"
      +    else
      +        echo "$HOME/.local/bin"
      +    fi
      +}
      +
       _nb_node_major() {
           local v
           v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
      @@ -187,10 +200,12 @@ _nb_install_bundled_node() {
           mv "$extracted" "$HERMES_HOME/node"
           rm -rf "$tmp"
       
      -    mkdir -p "$HOME/.local/bin"
      -    ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
      -    ln -sf "$HERMES_HOME/node/bin/npm"  "$HOME/.local/bin/npm"
      -    ln -sf "$HERMES_HOME/node/bin/npx"  "$HOME/.local/bin/npx"
      +    local _link_dir
      +    _link_dir="$(_nb_get_link_dir)"
      +    mkdir -p "$_link_dir"
      +    ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
      +    ln -sf "$HERMES_HOME/node/bin/npm"  "$_link_dir/npm"
      +    ln -sf "$HERMES_HOME/node/bin/npx"  "$_link_dir/npx"
           export PATH="$HERMES_HOME/node/bin:$PATH"
       
           _nb_have_modern_node || return 1
    • tests/hermes_cli/test_uninstall_node_symlinks.py
      @@ -130,3 +130,37 @@ def test_only_some_links_present(fake_home):
           assert (local_bin / "node").exists()
           assert not (local_bin / "npm").is_symlink()
           assert not (local_bin / "npx").is_symlink()
      +
      +
      +def test_removes_fhs_symlinks_in_usr_local_bin(fake_home, tmp_path, monkeypatch):
      +    """Root FHS installs place node symlinks in /usr/local/bin.
      +
      +    We monkeypatch _node_symlink_candidate_dirs to return a temp dir standing
      +    in for /usr/local/bin so the test doesn't need real root privileges.
      +    """
      +    hermes_home = fake_home / ".hermes"
      +    node_bin = _make_hermes_node(hermes_home)
      +
      +    # Fake /usr/local/bin as a temp dir with our symlinks.
      +    fhs_bin = tmp_path / "usr_local_bin"
      +    fhs_bin.mkdir()
      +    for name in ("node", "npm", "npx"):
      +        (fhs_bin / name).symlink_to(node_bin / name)
      +
      +    # Ensure ~/.local/bin has NO symlinks (simulate pure FHS install).
      +    local_bin = fake_home / ".local" / "bin"
      +    for name in ("node", "npm", "npx"):
      +        p = local_bin / name
      +        if p.exists() or p.is_symlink():
      +            p.unlink()
      +
      +    # Return only our fake FHS dir as a candidate.
      +    monkeypatch.setattr(
      +        uninstall, "_node_symlink_candidate_dirs", lambda: [fhs_bin]
      +    )
      +
      +    removed = uninstall.remove_node_symlinks(hermes_home)
      +
      +    assert sorted(p.name for p in removed) == ["node", "npm", "npx"]
      +    for name in ("node", "npm", "npx"):
      +        assert not (fhs_bin / name).is_symlink()
  • #46085 fix(install): make `npm install -g` packages reachable on PATH

    merged to main

    files: scripts/install.sh, scripts/lib/node-bootstrap.sh, tests/test_install_sh_node_global_prefix.py

    Show the diff
    • scripts/install.sh
      @@ -413,6 +413,25 @@ get_command_link_display_dir() {
           fi
       }
       
      +# Point a Hermes-managed Node's `npm install -g` at a directory that is on
      +# PATH. npm's default global prefix for a bundled Node is the Node dir itself,
      +# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on
      +# PATH (only the command link dir is) and is wiped on every Node upgrade.
      +# Redirecting the prefix to the link dir's parent makes global bins resolve to
      +# the command link dir (node/npm/npx live there too, already on PATH) and
      +# survive upgrades. Scoped to the managed Node via its prefix-local global
      +# npmrc, so the user's other Node installs and their ~/.npmrc are untouched.
      +# Hermes's own global installs pass an explicit --prefix and are unaffected.
      +# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on
      +# every install run repairs pre-existing installs, not just fresh ones.
      +configure_managed_node_npm_prefix() {
      +    [ -x "$HERMES_HOME/node/bin/npm" ] || return 0
      +    local link_dir
      +    link_dir="$(get_command_link_dir)"
      +    mkdir -p "$HERMES_HOME/node/etc"
      +    printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"
      +}
      +
       get_hermes_command_path() {
           local link_dir
           link_dir="$(get_command_link_dir)"
      @@ -722,6 +741,11 @@ node_satisfies_build() {
       check_node() {
           log_info "Checking Node.js (for browser tools)..."
       
      +    # Repair pre-existing Hermes-managed installs where `npm install -g` lands
      +    # off PATH. No-op when there's no managed Node, so this is safe to run on
      +    # every install — including re-runs that skip the Node (re)install below.
      +    configure_managed_node_npm_prefix
      +
           if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then
               log_success "Node.js $(node --version) found"
               HAS_NODE=true
      @@ -851,6 +875,8 @@ install_node() {
           ln -sf "$HERMES_HOME/node/bin/npm"  "$node_link_dir/npm"
           ln -sf "$HERMES_HOME/node/bin/npx"  "$node_link_dir/npx"
       
      +    configure_managed_node_npm_prefix
      +
           export PATH="$HERMES_HOME/node/bin:$PATH"
       
           local installed_ver
    • scripts/lib/node-bootstrap.sh
      @@ -57,6 +57,19 @@ _nb_get_link_dir() {
           fi
       }
       
      +# Redirect a Hermes-managed Node's `npm install -g` to the command link dir
      +# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off
      +# PATH and wiped on every Node upgrade. Scoped to the managed Node via its
      +# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are
      +# untouched. Idempotent no-op when there's no managed npm.
      +_nb_configure_npm_prefix() {
      +    [ -x "$HERMES_HOME/node/bin/npm" ] || return 0
      +    local _link_dir
      +    _link_dir="$(_nb_get_link_dir)"
      +    mkdir -p "$HERMES_HOME/node/etc"
      +    printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"
      +}
      +
       _nb_node_major() {
           local v
           v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
      @@ -206,6 +219,9 @@ _nb_install_bundled_node() {
           ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node"
           ln -sf "$HERMES_HOME/node/bin/npm"  "$_link_dir/npm"
           ln -sf "$HERMES_HOME/node/bin/npx"  "$_link_dir/npx"
      +
      +    _nb_configure_npm_prefix
      +
           export PATH="$HERMES_HOME/node/bin:$PATH"
       
           _nb_have_modern_node || return 1
      @@ -220,6 +236,10 @@ _nb_install_bundled_node() {
       ensure_node() {
           HERMES_NODE_AVAILABLE=false
       
      +    # Repair pre-existing managed installs where `npm install -g` lands off
      +    # PATH. No-op when there's no managed Node, so it's safe to run first.
      +    _nb_configure_npm_prefix
      +
           if _nb_have_modern_node; then
               _nb_ok "Node $(node --version) found"
               HERMES_NODE_AVAILABLE=true
    • tests/test_install_sh_node_global_prefix.py
      @@ -0,0 +1,55 @@
      +"""Regression tests for the Hermes-managed Node's npm global prefix.
      +
      +When the installer falls back to a bundled Node under ``$HERMES_HOME/node``,
      +npm's default global prefix is that Node dir, so ``npm install -g <pkg>``
      +drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH
      +(only the command link dir is) and is wiped on every Node upgrade. Users then
      +report "I can ``npm i -g`` but the package isn't usable on the command line".
      +
      +The fix redirects the bundled Node's global prefix to the command link dir's
      +parent (so global bins land in the already-on-PATH link dir alongside
      +node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc.
      +"""
      +
      +from pathlib import Path
      +
      +
      +REPO_ROOT = Path(__file__).resolve().parent.parent
      +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
      +NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
      +
      +
      +def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None:
      +    text = INSTALL_SH.read_text()
      +
      +    # The redirect must target the link dir's PARENT so global bins resolve to
      +    # <parent>/bin == the command link dir (node/npm/npx live there and it is
      +    # guaranteed on PATH by the installer's PATH setup).
      +    assert "configure_managed_node_npm_prefix()" in text
      +    assert 'printf \'prefix=%s\\n\' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text
      +
      +
      +def test_install_sh_repairs_existing_managed_node_on_rerun() -> None:
      +    """The redirect must run on every install (not just fresh Node installs),
      +    so re-running the installer repairs pre-existing managed installs whose
      +    Node is already up to date and would otherwise skip install_node."""
      +    text = INSTALL_SH.read_text()
      +
      +    check_node_body = text.split("check_node()", 1)[1].split("\ninstall_node()", 1)[0]
      +    assert "configure_managed_node_npm_prefix" in check_node_body
      +
      +    # No-op guard so it's safe to call when there is no managed Node.
      +    assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text
      +
      +
      +def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None:
      +    text = NODE_BOOTSTRAP.read_text()
      +
      +    assert "_nb_configure_npm_prefix()" in text
      +    assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text
      +
      +    # Runs at the top of ensure_node so existing managed installs are repaired
      +    # even when a modern Node is already present (early return path).
      +    ensure_node_body = text.split("ensure_node()", 1)[1]
      +    assert "_nb_configure_npm_prefix" in ensure_node_body
      +    assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text

7 PRs closed without merging

From the close comments: done elsewhere 2 · closed without comment 1 · merged via another change 1 · partial fix 1 · other 2

  • #18159 fix(doctor,browser): centralize tool PATH fallbacks (mise/asdf/nvm/Homebrew) · other
  • #18379 fix(install): prevent npm symlink from hijacking user global npm prefix · done elsewhere → #38889, #46085, #18357
  • #34538 fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall · merged via another change → 54aa4db, #34835, #34536
  • #45384 fix(installer): skip node/npm/npx symlinks for user installs (#45279) · other
  • #58099 fix(install): keep managed Node private for user installs · done elsewhere → 3d12e86, b63c138
Show 2 more
  • #63623 fix(installer): preserve user-managed Node commands · closed without comment
  • #99205 fix(install): restore user's fnm default Node version after bootstrap installs Hermes target · partial fix → #38765
Closed issues · 6
  • Closed#18357[Bug]: Setup SABOTAGES computer integrits - npm global installs hijacked to ~/.hermes/node breaks other software
  • Closed#34536Installer symlinks node/npm/npx into ~/.local/bin ahead of nvm, but uninstall leaves them behind
  • Closed#38765[Bug]: After installation, my fnm node config is overwrited.
  • Closed#45279PR #38889 still creates node/npm/npx shims in ~/.local/bin for user macOS installs, shadowing Homebrew/nvm
  • Closed#57997[Bug]: Installing Hermes Desktop broke iTerm disk access and nvm usage
  • Closed#63612[Bug]: Installer hijacks global node/npm/npx — GUI-launched install.sh misdetects Node as absent (launchd PATH), then ln -sf's Hermes-managed Node 22 over the user's ~/.local/bin
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.08: the share of item 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.

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 Lab

At a glance

Subsystem
agent
Merged PRs
#34835, #38889, #46085
Closed issues
6
Attempts
7 PRs closed without merging
Fix link checked
15 Jun 2026

For your agent

The same facts, typed.