# NousResearch/hermes-agent · Node/npm symlink conflicts with user version managers

## Open now

- 2 issues, 3 PRs open on GitHub.
- #18158: hermes doctor / browser tool can't find Node when launched non-interactively (mise/asdf/nvm shims, Homebrew)
- #64538: Browser tool cannot discover agent-browser installed by fnm on Windows
- PR #45282: fix(install): skip node/npm/npx symlinks when system versions exist
- PR #64545: fix(browser): discover fnm installs on Windows
- PR #111921: fix(installer): skip node/npm/npx symlinks for user installs

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.

## TL;DR

- 21 member(s): 2 open issue(s), 6 closed issue(s), 3 open PR(s), 3 merged PR(s), 7 PR(s) closed without merging.
- A fix was merged to main in PR #34835. Verify it addresses your case.
- **Recommended:** read PR #34835 (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: `partially_resolved` · deciding PR(s): `pr-34835`. 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.08`: 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.

## Open related issues

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

## Closed / duplicate issues

- #18357: \[Bug\]: Setup SABOTAGES computer integrits - npm global installs hijacked to ~/.hermes/node breaks other software
- #34536: Installer symlinks node/npm/npx into ~/.local/bin ahead of nvm, but uninstall leaves them behind
- #38765: \[Bug\]: After installation, my fnm node config is overwrited.
- #45279: PR #38889 still creates node/npm/npx shims in ~/.local/bin for user macOS installs, shadowing Homebrew/nvm
- #57997: \[Bug\]: Installing Hermes Desktop broke iTerm disk access and nvm usage
- #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

## Prior & competing PRs

- **#34835: fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/uninstall.py`, `pyproject.toml`, `tests/hermes_cli/test_uninstall_node_symlinks.py`
  - fix hunks:
  - `hermes_cli/uninstall.py`

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

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

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

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

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

    ```diff
    @@ -1659,6 +1660,7 @@ dev = [
         { name = "pytest-asyncio" },
         { name = "pytest-timeout" },
         { name = "ruff" },
    +    { name = "setuptools" },
         { name = "ty" },
     ]
     dingtalk = [
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/uninstall.py`, `scripts/install.sh`, `scripts/lib/node-bootstrap.sh`, `tests/hermes_cli/test_uninstall_node_symlinks.py`
  - fix hunks:
  - `hermes_cli/uninstall.py`

    ```diff
    @@ -9,6 +9,7 @@
     import os
     import shutil
     import subprocess
    +import sys
     from pathlib import Path
     
     from hermes_constants import get_hermes_home
    ```

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

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

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

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

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `scripts/install.sh`, `scripts/lib/node-bootstrap.sh`, `tests/test_install_sh_node_global_prefix.py`
  - fix hunks:
  - `scripts/install.sh`

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

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

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

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

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

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

    ```diff
    @@ -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
    ```
- **#18159: fix(doctor,browser): centralize tool PATH fallbacks (mise/asdf/nvm/Homebrew)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#18379: fix(install): prevent npm symlink from hijacking user global npm prefix**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#38889`, `#46085`, `#18357`
- **#34538: fix(cli): remove Hermes-managed node/npm/npx symlinks on uninstall**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `54aa4db1d`, `#34835`, `#34536`
- **#45282: fix(install): skip node/npm/npx symlinks when system versions exist**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#45279`
  - files: `scripts/install.sh`, `tests/test_install_sh_node_symlink_shadow.py`
- **#45384: fix(installer): skip node/npm/npx symlinks for user installs (#45279)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#58099: fix(install): keep managed Node private for user installs**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3d12e86ef13`, `b63c138d78`
- **#63623: fix(installer): preserve user-managed Node commands**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#64545: fix(browser): discover fnm installs on Windows**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `e5db60e4dd30`, `#64538`, `#64545`, `#64545`, `#64545`, `#64538`, `#374151`, `#64538`, `#64545`
  - files: `tests/tools/test_browser_fnm_paths.py`, `tools/browser_tool_install.py`, `tools/browser_tool_lifecycle.py`
- **#99205: fix(install): restore user's fnm default Node version after bootstrap installs Hermes target**. closed without merge
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `#38765`
- **#111921: fix(installer): skip node/npm/npx symlinks for user installs**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#45282`, `#45279`, `#45384`
  - files: `scripts/install.sh`

## Fix status

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