Compare commits

...

3 Commits

Author SHA1 Message Date
alt-glitch
200fc3c794 test(installer): factor node-bootstrap test layout into one helper
/simplify quality pass: the 5-segment Termux link-dir path was re-derived
in _run_nb_link and three test bodies; centralize it in a single
_layout(tmp_path) NamedTuple helper so the paths can't drift. Test-only,
no behavior change.
2026-06-04 19:42:07 +05:30
alt-glitch
4361159cbc fix(installer): close review gaps in node-on-PATH FHS heal
Follow-up hardening for the off-PATH node heal whose core landed via
PR #38889 (which squash-merged only the fresh-install link-dir fix). A
review of the full change surfaced the following, fixed here:

- install.sh: ensure_mode/postinstall_mode now call resolve_install_layout
  before check_node, so a root FHS box reached via `install.sh --ensure
  node` (dep_ensure / acp_adapter / TUI fallback) links node into
  /usr/local/bin instead of the off-PATH ~/.local/bin — the original
  #38889 regression still bit on those two paths.
- install.sh / node-bootstrap.sh: the best-effort stale-link prune now
  uses `rm -f ... 2>/dev/null || true` and the link helpers end with
  `return 0`, so a non-removable shadow link (read-only parent dir, uid
  mismatch) can no longer abort the whole installer under `set -e`.
- node-bootstrap.sh _nb_get_link_dir / hermes_constants _is_root_fhs_layout:
  handle the explicit --dir/$HERMES_INSTALL_DIR root install (which keeps
  ~/.local/bin) by placing node where the `hermes` command actually
  landed, instead of re-deriving a layout that diverges from the installer.
- whatsapp.py: launch the bridge with the bundled-fallback node binary and
  put the bundled node bin dir on the bridge PATH, so a bundled-but-off-PATH
  install doesn't FileNotFoundError at bridge launch (the check + npm install
  already used the fallback; the spawn didn't).
- doctor.py: diagnose a dangling /usr/local/bin/node symlink as a stale
  target (lexists/is_symlink) rather than misreporting it as missing.
- tests: add tests/test_node_bootstrap_link_prune.py covering the migration
  relink + stale-prune, prune safety (real files and user nvm/fnm links are
  preserved), idempotency, and the set -e prune-abort guard.
- docstring cleanups for the layout-aware wrapper-dir helpers.
2026-06-04 15:52:44 +05:30
alt-glitch
85b03a0c91 fix(installer): heal off-PATH node on update/migration + harden node discovery
Follow-up to the FHS root-install node-PATH fix, addressing the high-risk
gaps a reviewer flagged: fresh-install passing does not mean an existing
broken install gets healed.

Migration repair (the #1 trap):
- node-bootstrap.sh ensure_node() and install.sh check_node() both
  early-returned when a bundled node already existed at HERMES_HOME/node/bin,
  only fixing the current shell PATH and never re-creating the /usr/local/bin
  symlinks. A previously-broken root box therefore stayed broken after
  `hermes update` / re-install.
- Both paths now call a shared link_bundled_node / _nb_link_bundled_node that
  idempotently re-creates the symlinks in the canonical command-link dir AND
  prunes stale links left in the other candidate dirs, so a migrated root
  install no longer keeps shadowing copies in ~/.local/bin (the #34536
  nvm-shadow class).

Parity (messy-middle edge case):
- _nb_get_link_dir() now mirrors resolve_install_layout()'s legacy-install
  carve-out: a root user with HERMES_HOME/hermes-agent/.git keeps ~/.local/bin,
  so the bootstrap path can no longer link node to a different dir than the
  installer placed the hermes command.

Canonical helper (kills the duplicated layout-logic root cause):
- hermes_constants now owns command_link_dir, command_link_display_dir,
  command_link_candidate_dirs, bundled_node_bin_dir, find_node_executable.
  doctor.py, profiles.py, uninstall.py, backup.py, main.py all consume it.

Doctor now catches this class of regression:
- new _resolve_node_for_doctor reports "Node.js installed but not on PATH"
  instead of a false "not found", verifies the /usr/local/bin symlink on
  root FHS, self-heals PATH for the rest of the run, and the npm-audit block
  no longer silently vanishes when npm is off-PATH.
- doctor command-link detection uses the canonical helper, so it no longer
  looks in ~/.local/bin on root FHS or creates a wrong duplicate symlink
  with --fix.

Profile-alias wrappers now land in the layout-aware dir (was hardcoded
~/.local/bin, off-PATH for root FHS); remove_wrapper_script and uninstall
scan all candidate dirs.

Defensive bundled-node fallback (find_node_executable) added to the dashboard
web-UI build, WhatsApp bridge, and LSP installer so an off-PATH bundled node
does not silently disable those features.

Tests: 9 new hermes_constants helper tests + 4 profiles wrapper-dir tests.
Verified on a throwaway VM: fresh-root install (node on PATH, dashboard
serves HTTP 200, tsc present) and the migration scenario (broken old layout
re-installed -> node restored to /usr/local/bin, stale ~/.local/bin pruned).
2026-06-04 15:42:15 +05:30
13 changed files with 857 additions and 81 deletions

View File

@@ -245,6 +245,14 @@ def _install_npm(
needs ``typescript`` next to it; intelephense ships standalone).
"""
npm = shutil.which("npm")
if npm is None:
# Fall back to the bundled npm at <HERMES_HOME>/node/bin when off-PATH
# (e.g. root FHS install whose symlink is missing, #38889).
try:
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
except Exception:
npm = None
if npm is None:
logger.info("[install] cannot install %s: npm not on PATH", pkg)
return None

View File

@@ -197,10 +197,15 @@ 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")
# Check for Node.js. Resolve with bundled-fallback awareness (PATH first,
# then <HERMES_HOME>/node/bin) so a bundled-but-off-PATH install (e.g. a
# root FHS install whose symlink is missing, #38889) doesn't make the
# WhatsApp bridge silently unavailable.
try:
from hermes_constants import find_node_executable
_node = find_node_executable("node")
except Exception:
_node = shutil.which("node")
if not _node:
return False
try:
@@ -592,8 +597,16 @@ class WhatsAppAdapter(BasePlatformAdapter):
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"
# plain executable path. Fall back to the bundled npm at
# <HERMES_HOME>/node/bin when off-PATH (#38889).
_npm_bin = shutil.which("npm")
if not _npm_bin:
try:
from hermes_constants import find_node_executable
_npm_bin = find_node_executable("npm")
except Exception:
_npm_bin = None
_npm_bin = _npm_bin or "npm"
try:
# Read timeout from environment variable, default to 300 seconds (5 minutes)
# to accommodate slower systems like Unraid NAS
@@ -659,9 +672,32 @@ class WhatsAppAdapter(BasePlatformAdapter):
if self._reply_prefix is not None:
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
# Resolve node with bundled fallback and ensure the bundled node bin
# dir is on the bridge's PATH. The requirement check and `npm install`
# above already use find_node_executable, so on a bundled-but-off-PATH
# install (root FHS w/ missing symlink, #38889) they pass — but a bare
# "node" argv0 here would still raise FileNotFoundError, and the bridge
# itself shells out to node tooling, so it needs the dir on PATH too.
node_bin = "node"
try:
from hermes_constants import (
find_node_executable,
bundled_node_bin_dir,
)
node_bin = find_node_executable("node") or "node"
_node_dir = str(bundled_node_bin_dir())
_path = bridge_env.get("PATH", "")
if _node_dir not in _path.split(os.pathsep):
bridge_env["PATH"] = (
_node_dir + os.pathsep + _path if _path else _node_dir
)
except Exception:
node_bin = shutil.which("node") or "node"
self._bridge_process = subprocess.Popen(
[
"node",
node_bin,
str(bridge_path),
"--port", str(self._bridge_port),
"--session", str(self._session_path),

View File

@@ -448,9 +448,10 @@ def run_import(args) -> None:
if skipped:
print(f" Profile aliases skipped: {', '.join(skipped)}")
if not _is_wrapper_dir_in_path():
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
_wd = _get_wrapper_dir()
print(f"\n Note: {_wd} is not in your PATH.")
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
print(' export PATH="$HOME/.local/bin:$PATH"')
print(f' export PATH="{_wd}:$PATH"')
except ImportError:
# hermes_cli.profiles might not be available (fresh install)
if any(profiles_dir.iterdir()):

View File

@@ -13,6 +13,11 @@ from pathlib import Path
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
from hermes_constants import (
command_link_dir as _command_link_dir,
command_link_display_dir as _command_link_display_dir,
bundled_node_bin_dir as _bundled_node_bin_dir,
)
PROJECT_ROOT = get_project_root()
HERMES_HOME = get_hermes_home()
@@ -198,6 +203,80 @@ def _section(title: str) -> None:
print(color(f"{title}", Colors.CYAN, Colors.BOLD))
def _resolve_node_for_doctor(issues: list) -> str | None:
"""Resolve Node.js with bundled-fallback awareness and diagnose off-PATH.
Returns the resolved ``node`` binary path if node is usable from *some*
known location, else ``None``. Emits the appropriate check_ok/check_warn/
check_info lines and appends a fix to ``issues`` when node is installed but
unreachable via PATH (the PR #38889 class of regression: bundled node lives
at ``<HERMES_HOME>/node/bin`` but its PATH symlink is missing or off-PATH).
Discovery mirrors tools/browser_tool._browser_candidate_path_dirs and
hermes_cli/main._ensure_tui_node so doctor's verdict matches what actually
runs. As a side effect, when a bundled node is found off-PATH it is
prepended to ``os.environ["PATH"]`` for the remainder of this doctor run so
downstream npm/agent-browser checks don't cascade into false negatives.
"""
on_path = _safe_which("node")
if on_path:
check_ok("Node.js", f"({on_path})")
return on_path
# Not on PATH — is it installed at the bundled location?
bundled = _bundled_node_bin_dir() / "node"
if bundled.exists() and os.access(bundled, os.X_OK):
bin_dir = bundled.parent
check_warn(
"Node.js installed but not on PATH",
f"(found {bundled}, but `node` is not resolvable via PATH)",
)
# Root FHS installs are supposed to symlink node into /usr/local/bin.
# Verify that canonical symlink so doctor catches the exact PR #38889
# breakage rather than only the generic PATH miss.
try:
is_root = hasattr(os, "geteuid") and os.geteuid() == 0
except OSError:
is_root = False
if is_root and sys.platform == "linux":
fhs_link = Path("/usr/local/bin/node")
# Use lexists()/is_symlink(), not exists(): exists() follows the
# symlink, so a *dangling* link (target removed) would otherwise be
# misreported as "missing" and skip the stale-target diagnostic.
if not os.path.lexists(fhs_link):
check_info(
"Root FHS install: node should be linked into /usr/local/bin."
)
check_info(f"Fix: ln -sf {bundled} /usr/local/bin/node "
f"(and the same for npm, npx)")
issues.append(
"Bundled Node.js is off-PATH on a root FHS install — run: "
f"ln -sf {bundled} /usr/local/bin/node "
"(repeat for npm, npx), or re-run the installer"
)
elif not fhs_link.exists() or fhs_link.resolve() != bundled.resolve():
# Present but dangling (target gone) or pointing at the wrong node.
_actual = os.readlink(fhs_link) if fhs_link.is_symlink() else fhs_link
check_warn(
"/usr/local/bin/node points to the wrong target",
f"(→ {_actual}, expected {bundled})",
)
issues.append(
f"Fix stale node symlink: ln -sf {bundled} /usr/local/bin/node"
)
else:
check_info(f"Bundled Node.js exists at {bin_dir} but isn't on PATH.")
check_info(f'Fix: export PATH="{bin_dir}:$PATH" (add to your shell rc)')
issues.append(f"Node.js is installed but off-PATH — add {bin_dir} to PATH")
# Make the rest of the doctor run see this node so npm/agent-browser
# checks succeed instead of reporting more false negatives.
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
return str(bundled)
return None
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
"""Emit a check_fail and append the corresponding fix instruction."""
check_fail(text, detail)
@@ -1185,15 +1264,11 @@ def run_doctor(args):
_venv_bin = _candidate
break
# Determine the expected command link directory (mirrors install.sh logic)
_prefix = os.environ.get("PREFIX", "")
_is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix
if _is_termux_env and _prefix:
_cmd_link_dir = Path(_prefix) / "bin"
_cmd_link_display = "$PREFIX/bin"
else:
_cmd_link_dir = Path.home() / ".local" / "bin"
_cmd_link_display = "~/.local/bin"
# Determine the expected command link directory (canonical helper —
# single source of truth shared with scripts/install.sh, so root FHS
# installs correctly resolve to /usr/local/bin instead of ~/.local/bin).
_cmd_link_dir = _command_link_dir()
_cmd_link_display = _command_link_display_dir()
_cmd_link = _cmd_link_dir / "hermes"
if _venv_bin is None:
@@ -1244,7 +1319,7 @@ def run_doctor(args):
if str(_cmd_link_dir) not in _path_dirs:
check_warn(
f"{_cmd_link_display} is not on your PATH",
"(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")"
f'(add it to your shell config: export PATH="{_cmd_link_dir}:$PATH")'
)
manual_issues.append(f"Add {_cmd_link_display} to your PATH")
else:
@@ -1373,8 +1448,10 @@ def run_doctor(args):
)
# Node.js + agent-browser (for browser automation tools)
if _safe_which("node"):
check_ok("Node.js")
# Resolve with bundled-fallback awareness so an off-PATH bundled install
# is diagnosed as "installed but not on PATH" instead of "not found".
_node_resolved = _resolve_node_for_doctor(issues)
if _node_resolved:
# Check if agent-browser is installed
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
agent_browser_ok = False
@@ -1451,8 +1528,15 @@ def run_doctor(args):
else:
check_warn("Node.js not found", "(optional, needed for browser tools)")
# npm audit for all Node.js packages
# npm audit for all Node.js packages. Use bundled-fallback resolution so a
# bundled-but-off-PATH npm is still found (the _resolve_node_for_doctor call
# above already prepended the bundled bin dir to PATH for this run, so plain
# which usually works now; the explicit fallback is belt-and-suspenders).
_npm_bin = _safe_which("npm")
if not _npm_bin:
_bundled_npm = _bundled_node_bin_dir() / "npm"
if _bundled_npm.exists() and os.access(_bundled_npm, os.X_OK):
_npm_bin = str(_bundled_npm)
if _npm_bin:
npm_dirs = [
(PROJECT_ROOT, "Browser tools (agent-browser)"),

View File

@@ -6817,6 +6817,7 @@ def _run_with_idle_timeout(
*,
idle_timeout_seconds: int = 180,
indent: str = " ",
env: dict | None = None,
) -> subprocess.CompletedProcess:
"""Run a subprocess that streams output, with an idle-output timeout.
@@ -6851,6 +6852,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.
@@ -6915,6 +6917,7 @@ def _run_npm_install_deterministic(
*,
extra_args: tuple[str, ...] = (),
capture_output: bool = True,
env: dict | None = None,
) -> subprocess.CompletedProcess:
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
@@ -6936,6 +6939,7 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
if ci_result.returncode == 0:
return ci_result
@@ -6950,6 +6954,7 @@ def _run_npm_install_deterministic(
encoding="utf-8",
errors="replace",
check=False,
env=env,
)
@@ -6981,12 +6986,44 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
# Resolve npm with bundled-fallback awareness: on a root FHS install whose
# PATH symlink is missing, or any context with a stripped PATH (systemd
# service, RHEL non-login shell), shutil.which("npm") returns None even
# though the bundled npm exists at <HERMES_HOME>/node/bin/npm. See #38889.
npm = shutil.which("npm")
if not npm:
try:
from hermes_constants import find_node_executable
npm = find_node_executable("npm")
except Exception:
npm = None
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
# Ensure the bundled node/bin dir is on PATH for the build subprocesses so
# the `npm run build` step (which shells out to tsc / vite from
# node_modules/.bin, and those re-invoke `node`) can find node even when the
# caller's PATH doesn't include it.
_build_env = None
try:
from hermes_constants import bundled_node_bin_dir
_node_bin = bundled_node_bin_dir()
if _node_bin.is_dir():
_build_env = os.environ.copy()
_existing = _build_env.get("PATH", "")
if str(_node_bin) not in _existing.split(os.pathsep):
_build_env["PATH"] = str(_node_bin) + os.pathsep + _existing
# Also fold in the resolved npm's own dir (covers system node installs).
_npm_dir = str(Path(npm).resolve().parent)
if _build_env is None:
_build_env = os.environ.copy()
if _npm_dir not in _build_env.get("PATH", "").split(os.pathsep):
_build_env["PATH"] = _npm_dir + os.pathsep + _build_env.get("PATH", "")
except Exception:
_build_env = None
_say("→ Building web UI...")
def _relay(result: "subprocess.CompletedProcess") -> None:
@@ -7008,6 +7045,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
npm,
_workspace_root(web_dir),
extra_args=("--silent",),
env=_build_env,
)
if r1.returncode != 0:
_say(
@@ -7023,13 +7061,13 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
# 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
@@ -11438,11 +11476,12 @@ def cmd_profile(args):
if wrapper_path:
print(f"Wrapper created: {wrapper_path}")
if not _is_wrapper_dir_in_path():
print(f"\n{_get_wrapper_dir()} is not in your PATH.")
_wd = _get_wrapper_dir()
print(f"\n{_wd} is not in your PATH.")
print(
f" Add to your shell config (~/.bashrc or ~/.zshrc):"
)
print(f' export PATH="$HOME/.local/bin:$PATH"')
print(f' export PATH="{_wd}:$PATH"')
# Profile dir for display
try:

View File

@@ -240,8 +240,25 @@ def _get_active_profile_path() -> Path:
def _get_wrapper_dir() -> Path:
"""Return the directory for wrapper scripts."""
return Path.home() / ".local" / "bin"
"""Return the directory for profile-alias wrapper scripts.
Uses the canonical command-link directory so aliases land wherever the
``hermes`` command itself lives and is therefore on PATH: ``/usr/local/bin``
for root FHS installs, ``$PREFIX/bin`` on Termux, ``~/.local/bin`` otherwise
(including Windows). Previously hardcoded ``~/.local/bin``, which left
aliases off-PATH on root FHS installs (PR #38889).
"""
from hermes_constants import command_link_dir
return command_link_dir()
def _wrapper_candidate_dirs() -> list[Path]:
"""All dirs a profile alias may live in, for cleanup that must find links
regardless of which layout created them."""
from hermes_constants import command_link_candidate_dirs
return command_link_candidate_dirs()
# ---------------------------------------------------------------------------
@@ -354,13 +371,16 @@ def check_alias_collision(name: str) -> Optional[str]:
def _is_wrapper_dir_in_path() -> bool:
"""Check if ~/.local/bin is in PATH."""
"""Check if the layout-aware wrapper dir (see ``_get_wrapper_dir``) is in PATH."""
wrapper_dir = str(_get_wrapper_dir())
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
"""Create a shell wrapper script at ~/.local/bin/<name>.
"""Create a shell wrapper script at ``<wrapper_dir>/<name>``.
``<wrapper_dir>`` is layout-aware (``_get_wrapper_dir``): ``/usr/local/bin``
for a root FHS install, ``$PREFIX/bin`` on Termux, else ``~/.local/bin``.
The wrapper file is named after ``name`` (the alias). The profile it
activates is ``target`` if given, otherwise ``name`` — this lets a custom
@@ -399,27 +419,34 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
def remove_wrapper_script(name: str) -> bool:
"""Remove the wrapper script for a profile. Returns True if removed."""
wrapper_dir = _get_wrapper_dir()
"""Remove the wrapper script for a profile. Returns True if removed.
Scans all candidate command-link directories (``~/.local/bin``,
``/usr/local/bin``, ``$PREFIX/bin``) so aliases are removable regardless of
which layout created them — e.g. an alias written to ``/usr/local/bin`` on a
root FHS install, or a legacy one left in ``~/.local/bin``.
"""
canon = normalize_profile_name(name)
is_windows = sys.platform == "win32"
# Check both the extensionless path (POSIX) and .bat (Windows)
candidates = [wrapper_dir / canon]
if is_windows:
candidates.insert(0, wrapper_dir / f"{canon}.bat")
removed = False
for wrapper_dir in _wrapper_candidate_dirs():
# Check both the extensionless path (POSIX) and .bat (Windows)
candidates = [wrapper_dir / canon]
if is_windows:
candidates.insert(0, wrapper_dir / f"{canon}.bat")
for wrapper_path in candidates:
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
return True
except Exception:
pass
return False
for wrapper_path in candidates:
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
content = wrapper_path.read_text()
if "hermes -p" in content:
wrapper_path.unlink()
removed = True
except Exception:
pass
return removed
# ---------------------------------------------------------------------------

View File

@@ -119,16 +119,14 @@ def remove_wrapper_script():
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
"""Directories where the installer may have placed node/npm/npx symlinks.
Delegates to the canonical helper in hermes_constants so the layout logic
lives in exactly one place (shared with profiles, doctor, backup).
"""
from hermes_constants import command_link_candidate_dirs
return command_link_candidate_dirs()
def remove_node_symlinks(hermes_home: Path) -> list:
@@ -474,14 +472,28 @@ def _uninstall_profile(profile) -> None:
except Exception as e:
log_warn(f" Could not run gateway {subcmd} for '{name}': {e}")
# 2. Remove the wrapper alias script at ~/.local/bin/<name> (if any).
alias_path = getattr(profile, "alias_path", None)
if alias_path and alias_path.exists():
try:
alias_path.unlink()
log_success(f" Removed alias {alias_path}")
except Exception as e:
log_warn(f" Could not remove alias {alias_path}: {e}")
# 2. Remove the wrapper alias script wherever it landed. Use the
# profiles helper which scans all candidate command-link dirs
# (~/.local/bin, /usr/local/bin, $PREFIX/bin) so root FHS aliases are
# removed too — then fall back to the recorded alias_path for safety.
removed_alias = False
try:
from hermes_cli.profiles import remove_wrapper_script
removed_alias = remove_wrapper_script(name)
if removed_alias:
log_success(f" Removed profile alias '{name}'")
except Exception as e:
log_warn(f" Could not scan for profile alias '{name}': {e}")
if not removed_alias:
alias_path = getattr(profile, "alias_path", None)
if alias_path and alias_path.exists():
try:
alias_path.unlink()
log_success(f" Removed alias {alias_path}")
except Exception as e:
log_warn(f" Could not remove alias {alias_path}: {e}")
# 3. Wipe the profile's HERMES_HOME directory.
try:

View File

@@ -414,6 +414,156 @@ def get_env_path() -> Path:
return get_hermes_home() / ".env"
# ─── Command-Link & Bundled-Node Locations ───────────────────────────────────
#
# Canonical, single source of truth for *where the installer places executables
# so they land on PATH*. This MUST stay in lockstep with the bash helper
# ``get_command_link_dir()`` in ``scripts/install.sh`` and ``_nb_get_link_dir()``
# in ``scripts/lib/node-bootstrap.sh``. Historically this logic was duplicated
# (and went stale) in doctor.py, profiles.py, uninstall.py and backup.py, which
# caused root-FHS installs to look for / write the ``hermes`` command and node
# symlinks in ``~/.local/bin`` even though they actually live in
# ``/usr/local/bin``. See PR #38889.
def _is_root_fhs_layout() -> bool:
"""Return True when this is a root install using the Linux FHS layout.
Heuristic (not a strict line-by-line mirror) for ``resolve_install_layout()``
in ``scripts/install.sh``: root (uid 0) on Linux normally uses
``/usr/local/lib/hermes-agent`` for code and ``/usr/local/bin`` for the
command link. We can't see the installer's ``--dir``/``$HERMES_INSTALL_DIR``
or legacy-install flags from here, so we infer the layout from on-disk
evidence instead, preferring it over the bare uid check:
* legacy git install at ``<HERMES_HOME>/hermes-agent`` → not FHS
(``resolve_install_layout`` keeps ``~/.local/bin`` for it);
* ``/usr/local`` markers present (command link or code dir) → FHS;
* a ``~/.local/bin/hermes`` command present (e.g. an explicit ``--dir`` root
install, which ``resolve_install_layout`` does NOT flip to FHS) → not FHS;
* no evidence at all (e.g. mid-install) → assume FHS, the root default.
"""
if sys.platform != "linux":
return False
try:
if not hasattr(os, "geteuid") or os.geteuid() != 0:
return False
except OSError:
return False
# A legacy git install at <HERMES_HOME>/hermes-agent means
# resolve_install_layout() kept the ~/.local/bin layout — mirror that.
if (get_hermes_home() / "hermes-agent" / ".git").exists():
return False
if Path("/usr/local/bin/hermes").exists():
return True
if Path("/usr/local/lib/hermes-agent").exists():
return True
# No /usr/local markers: a root user who installed into ~/.local/bin (e.g.
# via --dir/$HERMES_INSTALL_DIR, where resolve_install_layout() does not flip
# to FHS) keeps the command there. Honor that evidence before assuming FHS,
# so command_link_dir() doesn't point at /usr/local/bin for such a box.
if (Path.home() / ".local" / "bin" / "hermes").exists():
return False
# No markers at all (e.g. mid-install): the installer defaults a fresh root
# Linux box to the FHS layout, so assume FHS.
return True
def command_link_dir() -> Path:
"""Return the directory where the ``hermes`` command (and bundled node/npm/
npx symlinks, profile-alias wrappers) are placed so they land on PATH.
Resolution mirrors ``get_command_link_dir()`` in ``scripts/install.sh``:
* Termux → ``$PREFIX/bin``
* root FHS install on Linux → ``/usr/local/bin``
* everything else (the common non-root case, and Windows) → ``~/.local/bin``
"""
if is_termux():
prefix = os.environ.get("PREFIX", "").strip()
if prefix:
return Path(prefix) / "bin"
if _is_root_fhs_layout():
return Path("/usr/local/bin")
return Path.home() / ".local" / "bin"
def command_link_display_dir() -> str:
"""User-friendly display string for :func:`command_link_dir`.
Uses ``~/.local/bin`` shorthand and ``$PREFIX/bin`` for Termux, matching
``get_command_link_display_dir()`` in ``scripts/install.sh``.
"""
if is_termux() and os.environ.get("PREFIX", "").strip():
return "$PREFIX/bin"
if _is_root_fhs_layout():
return "/usr/local/bin"
return "~/.local/bin"
def command_link_candidate_dirs() -> list[Path]:
"""All directories the installer may have placed command links in.
Used by uninstall and other cleanup paths that must find links regardless
of which layout created them (e.g. an old ``~/.local/bin`` install upgraded
to FHS, or vice-versa). Always includes ``~/.local/bin`` plus the
layout-specific dirs, de-duplicated and order-preserving.
"""
dirs: list[Path] = [Path.home() / ".local" / "bin"]
if sys.platform == "linux":
dirs.append(Path("/usr/local/bin"))
prefix = os.environ.get("PREFIX", "").strip()
if prefix and "com.termux" in prefix:
dirs.append(Path(prefix) / "bin")
# De-dupe while preserving order.
seen: set[str] = set()
out: list[Path] = []
for d in dirs:
key = str(d)
if key not in seen:
seen.add(key)
out.append(d)
return out
def bundled_node_bin_dir() -> Path:
"""Return the bundled Node.js ``bin`` directory: ``<HERMES_HOME>/node/bin``.
This is where ``install_node()`` / ``node-bootstrap.sh`` extract the
Hermes-managed Node runtime. Profile-aware via :func:`get_hermes_home`.
Discovery code that gates a feature on ``node``/``npm``/``npx`` should fall
back to this directory when ``shutil.which`` returns nothing, so a misplaced
or missing PATH symlink doesn't make an installed runtime invisible.
"""
return get_hermes_home() / "node" / "bin"
def find_node_executable(name: str = "node") -> str | None:
"""Resolve a Node executable (node/npm/npx) with bundled fallback.
Returns an absolute path string if found on PATH or in the bundled
``<HERMES_HOME>/node/bin`` directory, else ``None``. Prefer this over a
bare ``shutil.which(name)`` anywhere a feature depends on Node, so an
off-PATH bundled install still works.
"""
import shutil
on_path = shutil.which(name)
if on_path:
return on_path
candidate = bundled_node_bin_dir() / name
if sys.platform == "win32" and not candidate.suffix:
# Windows ships node.exe / npm.cmd; try common suffixes.
for suffix in (".exe", ".cmd", ".bat", ""):
c = candidate.with_suffix(suffix) if suffix else candidate
if c.exists() and os.access(c, os.X_OK):
return str(c)
return None
if candidate.exists() and os.access(candidate, os.X_OK):
return str(candidate)
return None
# ─── Network Preferences ─────────────────────────────────────────────────────

View File

@@ -731,6 +731,12 @@ check_node() {
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
if [ -x "$HERMES_HOME/node/bin/node" ] && node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
export PATH="$HERMES_HOME/node/bin:$PATH"
# Migration repair (#38889): a previously-broken install may have its
# node symlinks only in ~/.local/bin (off-PATH on root FHS) or missing.
# Re-link into the canonical dir + prune stale copies so re-running the
# installer (or `hermes update`) heals the box instead of leaving it
# broken.
link_bundled_node
log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)"
HAS_NODE=true
return 0
@@ -746,6 +752,36 @@ check_node() {
install_node
}
# Idempotently (re)create node/npm/npx PATH symlinks in the command-link dir
# and prune stale ones in the other candidate dirs. Shared by install_node
# (fresh install) and check_node (migration repair of an existing broken box,
# #38889). Pruning only removes symlinks that resolve into THIS Hermes home's
# node dir — never a real binary or a user's nvm/fnm link.
link_bundled_node() {
local node_link_dir stale_dir name target
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"
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
[ "$stale_dir" = "$node_link_dir" ] && continue
for name in node npm npx; do
[ -L "$stale_dir/$name" ] || continue
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
case "$target" in
# `|| true`: pruning a shadow link is best-effort. A failing
# rm (read-only parent dir, uid mismatch) must NOT abort the
# whole installer via `set -e` (line 16). See #38889.
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
esac
done
done
# Never let this best-effort helper be the failing last command under set -e.
return 0
}
install_node() {
if [ "$DISTRO" = "termux" ]; then
log_info "Installing Node.js via pkg..."
@@ -844,12 +880,7 @@ install_node() {
mv "$extracted_dir" "$HERMES_HOME/node"
rm -rf "$tmp_dir"
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"
link_bundled_node
export PATH="$HERMES_HOME/node/bin:$PATH"
@@ -2179,6 +2210,12 @@ ensure_browser() {
ensure_mode() {
detect_os
# Resolve the install layout so $ROOT_FHS_LAYOUT is set before check_node →
# install_node → get_command_link_dir() decides where to symlink node/npm/npx.
# Without this, a root FHS box reached via `install.sh --ensure node`
# (hermes_cli/dep_ensure.py, acp_adapter, TUI fallback) leaves ROOT_FHS_LAYOUT
# false and links node into ~/.local/bin (off-PATH) — the #38889 regression.
resolve_install_layout
IFS=',' read -ra DEPS <<< "$ENSURE_DEPS"
for dep in "${DEPS[@]}"; do
@@ -2217,6 +2254,9 @@ ensure_mode() {
postinstall_mode() {
print_banner
detect_os
# Set $ROOT_FHS_LAYOUT before check_node/install_node so node/npm/npx are
# symlinked into the same dir as the hermes command on a root FHS box. (#38889)
resolve_install_layout
log_info "Post-install mode: setting up Hermes for pip install"

View File

@@ -47,14 +47,70 @@ _nb_is_termux() {
# 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.
#
# Parity note (#38889): install.sh keys off $ROOT_FHS_LAYOUT, which
# resolve_install_layout() leaves FALSE (→ ~/.local/bin) for a root user with
# EITHER a legacy git install at $HERMES_HOME/hermes-agent/.git OR an explicit
# --dir/$HERMES_INSTALL_DIR install (INSTALL_DIR_EXPLICIT). The bootstrap can't
# see those flags, so it can't recompute the layout — instead it puts node where
# the `hermes` command actually landed. That keeps node and the command in the
# same dir no matter how the box was installed, which is the only invariant that
# matters here and avoids re-deriving (and diverging from) the installer's logic.
_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"
return
fi
if [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
# Legacy git install keeps ~/.local/bin (matches resolve_install_layout).
if [ -d "${HERMES_HOME:-$HOME/.hermes}/hermes-agent/.git" ]; then
echo "$HOME/.local/bin"
return
fi
# Explicit --dir root installs keep the command in ~/.local/bin; detect
# that from where `hermes` actually is rather than re-deriving the flag.
if [ -e "$HOME/.local/bin/hermes" ] && [ ! -e "/usr/local/bin/hermes" ]; then
echo "$HOME/.local/bin"
return
fi
# Fresh/standard root FHS install.
echo "/usr/local/bin"
return
fi
echo "$HOME/.local/bin"
}
# Idempotently (re)create the node/npm/npx PATH symlinks in the canonical link
# dir, and prune stale ones left in OTHER candidate dirs by an older/broken
# install (the #38889 migration case: a root box upgraded from the old layout
# has links only in ~/.local/bin, off-PATH). Safe to call repeatedly.
#
# Pruning rule mirrors hermes_cli/uninstall.remove_node_symlinks: only remove a
# symlink that still resolves into THIS Hermes home's node dir — never touch a
# real binary or a link the user repointed at nvm/fnm.
_nb_link_bundled_node() {
local link_dir stale_dir name target
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"
# Prune stale links in the other candidate dirs (so a migrated root install
# doesn't keep shadowing copies in ~/.local/bin — #34536 nvm-shadow class).
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
[ "$stale_dir" = "$link_dir" ] && continue
for name in node npm npx; do
[ -L "$stale_dir/$name" ] || continue
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
case "$target" in
# `|| true`: best-effort prune must never fail a caller that
# runs under `set -e` (install.sh sources/mirrors this). #38889
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
esac
done
done
return 0
}
_nb_node_major() {
@@ -200,12 +256,8 @@ _nb_install_bundled_node() {
mv "$extracted" "$HERMES_HOME/node"
rm -rf "$tmp"
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"
# Create PATH symlinks in the canonical link dir (and prune stale ones).
_nb_link_bundled_node
export PATH="$HERMES_HOME/node/bin:$PATH"
_nb_have_modern_node || return 1
@@ -229,6 +281,12 @@ ensure_node() {
if [ -x "$HERMES_HOME/node/bin/node" ]; then
export PATH="$HERMES_HOME/node/bin:$PATH"
if _nb_have_modern_node; then
# Migration repair (#38889): an existing install may have its node
# symlinks only in ~/.local/bin (off-PATH on root FHS) or missing
# entirely. Re-create them in the canonical link dir and prune
# stale copies, so `hermes update` heals a previously-broken box
# instead of silently leaving it broken.
_nb_link_bundled_node
_nb_ok "Node $(node --version) found (Hermes-managed)"
HERMES_NODE_AVAILABLE=true
return 0

View File

@@ -1288,3 +1288,48 @@ class TestEdgeCases:
delete_profile("coder", yes=True)
assert get_active_profile() == "default"
class TestWrapperDirLayoutAware:
"""Profile-alias wrapper dir follows the canonical command-link layout (#38889)."""
def test_wrapper_dir_root_fhs(self, monkeypatch):
import hermes_cli.profiles as profiles
import hermes_constants
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
assert profiles._get_wrapper_dir() == Path("/usr/local/bin")
def test_wrapper_dir_nonroot(self, tmp_path, monkeypatch):
import hermes_cli.profiles as profiles
import hermes_constants
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
assert profiles._get_wrapper_dir() == tmp_path / ".local" / "bin"
def test_remove_wrapper_scans_all_dirs(self, tmp_path, monkeypatch):
"""An alias in /usr/local/bin is removed even though _get_wrapper_dir
would (in test) point at ~/.local/bin — remove must scan candidates."""
import hermes_cli.profiles as profiles
fake_usr_local = tmp_path / "usr_local_bin"
fake_usr_local.mkdir()
alias = fake_usr_local / "myprof"
alias.write_text('#!/usr/bin/env bash\nexec hermes -p myprof "$@"\n')
alias.chmod(0o755)
monkeypatch.setattr(
profiles, "_wrapper_candidate_dirs", lambda: [fake_usr_local]
)
assert profiles.remove_wrapper_script("myprof") is True
assert not alias.exists()
def test_remove_wrapper_leaves_foreign_files(self, tmp_path, monkeypatch):
"""A file that isn't our wrapper (no 'hermes -p') is left untouched."""
import hermes_cli.profiles as profiles
d = tmp_path / "bin"
d.mkdir()
foreign = d / "myprof"
foreign.write_text("#!/bin/sh\necho not ours\n")
monkeypatch.setattr(profiles, "_wrapper_candidate_dirs", lambda: [d])
assert profiles.remove_wrapper_script("myprof") is False
assert foreign.exists()

View File

@@ -298,3 +298,66 @@ class TestSecureParentDir:
assert len(called_with) == 1
assert called_with[0] == (str(real_dir), 0o700)
class TestCommandLinkDir:
"""Tests for the canonical command-link / bundled-node helpers (#38889)."""
def test_nonroot_returns_local_bin(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.delenv("PREFIX", raising=False)
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
assert hermes_constants.command_link_dir() == tmp_path / ".local" / "bin"
assert hermes_constants.command_link_display_dir() == "~/.local/bin"
def test_root_fhs_returns_usr_local_bin(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
assert hermes_constants.command_link_dir() == Path("/usr/local/bin")
assert hermes_constants.command_link_display_dir() == "/usr/local/bin"
def test_termux_returns_prefix_bin(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_termux", lambda: True)
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
assert hermes_constants.command_link_dir() == Path(
"/data/data/com.termux/files/usr/bin"
)
assert hermes_constants.command_link_display_dir() == "$PREFIX/bin"
def test_candidate_dirs_includes_both_on_linux(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
monkeypatch.delenv("PREFIX", raising=False)
dirs = hermes_constants.command_link_candidate_dirs()
assert tmp_path / ".local" / "bin" in dirs
assert Path("/usr/local/bin") in dirs
def test_candidate_dirs_deduped(self, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
monkeypatch.delenv("PREFIX", raising=False)
dirs = hermes_constants.command_link_candidate_dirs()
assert len(dirs) == len({str(d) for d in dirs})
def test_bundled_node_bin_dir(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert hermes_constants.bundled_node_bin_dir() == tmp_path / "node" / "bin"
def test_find_node_prefers_path(self, monkeypatch):
monkeypatch.setattr("shutil.which", lambda n: "/usr/bin/" + n)
assert hermes_constants.find_node_executable("node") == "/usr/bin/node"
def test_find_node_falls_back_to_bundled(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("shutil.which", lambda n: None)
node_bin = tmp_path / "node" / "bin"
node_bin.mkdir(parents=True)
(node_bin / "npm").write_text("#!/bin/sh\n")
(node_bin / "npm").chmod(0o755)
assert hermes_constants.find_node_executable("npm") == str(node_bin / "npm")
def test_find_node_returns_none_when_absent(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("shutil.which", lambda n: None)
assert hermes_constants.find_node_executable("node") is None

View File

@@ -0,0 +1,213 @@
"""Regression coverage for the #38889 node migration-heal + stale-prune.
The user-facing fix reviewers cared about most — re-running the installer (or
``hermes update``) on a box whose node symlinks landed in the wrong (off-PATH)
dir re-links node into the canonical command dir AND prunes the stale shadow
copies — lives entirely in shell (``link_bundled_node`` in ``install.sh`` and
``_nb_link_bundled_node`` in ``scripts/lib/node-bootstrap.sh``). Before this
file it had no automated coverage at all (only a manual VM run).
These tests drive the sourceable ``node-bootstrap.sh`` helper directly. The FHS
``/usr/local/bin`` target requires root, so to exercise the same relink+prune
code path with a *writable* link dir we run in Termux mode (``$PREFIX/bin`` is
the link dir), which makes ``~/.local/bin`` one of the scanned stale dirs. The
prune logic, safety guards, idempotency, and the ``set -e`` hardening are
identical across the FHS and Termux link dirs.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import NamedTuple
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
pytestmark = pytest.mark.skipif(
sys.platform.startswith("win") or shutil.which("bash") is None,
reason="POSIX shell required to drive node-bootstrap.sh",
)
class _Layout(NamedTuple):
home: Path
hermes_home: Path
node_bin: Path
prefix: Path
link_dir: Path
local_bin: Path
def _layout(tmp_path: Path) -> _Layout:
"""The fixed dir layout these tests share.
Termux mode (PREFIX contains ``com.termux/files/usr``) makes the link dir
``$PREFIX/bin``, so ``~/.local/bin`` is a *scanned, writable* stale dir —
the only way to exercise the relink+prune without being root.
"""
home = tmp_path / "home"
hermes_home = tmp_path / "hermes"
prefix = tmp_path / "termux" / "com.termux" / "files" / "usr"
return _Layout(
home=home,
hermes_home=hermes_home,
node_bin=hermes_home / "node" / "bin",
prefix=prefix,
link_dir=prefix / "bin",
local_bin=home / ".local" / "bin",
)
def _make_bundled_node(hermes_home: Path) -> Path:
"""Create dummy <HERMES_HOME>/node/bin/{node,npm,npx} executables."""
node_bin = hermes_home / "node" / "bin"
node_bin.mkdir(parents=True)
for name in ("node", "npm", "npx"):
exe = node_bin / name
exe.write_text("#!/bin/sh\necho dummy\n")
exe.chmod(0o755)
return node_bin
def _run_nb_link(tmp_path: Path, *, extra: str = "") -> subprocess.CompletedProcess:
"""Source node-bootstrap.sh in Termux mode and run _nb_link_bundled_node.
Runs under ``set -e`` so the prune's best-effort ``rm`` failures must not
abort (the #38889 hardening); ``SENTINEL_OK`` after the call proves we
returned normally.
"""
lay = _layout(tmp_path)
lay.link_dir.mkdir(parents=True, exist_ok=True)
lay.local_bin.mkdir(parents=True, exist_ok=True)
env = {
"HOME": str(lay.home),
"PREFIX": str(lay.prefix),
"HERMES_HOME": str(lay.hermes_home),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
}
script = textwrap.dedent(
f"""
set -e
source "{NODE_BOOTSTRAP}"
{extra}
_nb_link_bundled_node
echo SENTINEL_OK
"""
)
return subprocess.run(
["bash", "-c", script], env=env, capture_output=True, text=True
)
def test_relinks_and_prunes_stale_hermes_shadows(tmp_path: Path) -> None:
"""node/npm links into the bundle's node dir are pruned; the canonical link
dir gets fresh links; non-hermes links and real files are left alone."""
lay = _layout(tmp_path)
node_bin = _make_bundled_node(lay.hermes_home)
link_dir = lay.link_dir
local_bin = lay.local_bin
# Simulate an old/broken install: hermes-owned shadow links in ~/.local/bin.
local_bin.mkdir(parents=True, exist_ok=True)
(local_bin / "node").symlink_to(node_bin / "node") # hermes → PRUNE
(local_bin / "npm").write_text("#!/bin/sh\n") # real file → KEEP
(local_bin / "npm").chmod(0o755)
nvm_npx = tmp_path / "fake_nvm" / "bin" / "npx"
nvm_npx.parent.mkdir(parents=True)
nvm_npx.write_text("#!/bin/sh\n")
(local_bin / "npx").symlink_to(nvm_npx) # user link → KEEP
result = _run_nb_link(tmp_path)
assert result.returncode == 0, result.stderr
assert "SENTINEL_OK" in result.stdout
# Canonical link dir now has all three pointing into the bundle.
for name in ("node", "npm", "npx"):
link = link_dir / name
assert link.is_symlink(), f"{name} not linked into canonical dir"
assert link.resolve() == (node_bin / name).resolve()
# Stale hermes shadow was pruned; the real file and the nvm link survived.
assert not (local_bin / "node").exists() and not (local_bin / "node").is_symlink(), (
"stale hermes-owned ~/.local/bin/node should have been pruned"
)
assert (local_bin / "npm").is_file() and not (local_bin / "npm").is_symlink(), (
"a real binary must never be removed by the prune"
)
assert (local_bin / "npx").is_symlink() and (local_bin / "npx").resolve() == nvm_npx.resolve(), (
"a user's nvm/fnm link must never be removed by the prune"
)
def test_idempotent_across_repeated_runs(tmp_path: Path) -> None:
"""Running the heal twice converges to the same state (no thrash/dup)."""
lay = _layout(tmp_path)
node_bin = _make_bundled_node(lay.hermes_home)
link_dir = lay.link_dir
first = _run_nb_link(tmp_path)
assert first.returncode == 0, first.stderr
# Second run with the canonical links already in place.
second = _run_nb_link(tmp_path)
assert second.returncode == 0, second.stderr
assert "SENTINEL_OK" in second.stdout
for name in ("node", "npm", "npx"):
link = link_dir / name
assert link.is_symlink()
assert link.resolve() == (node_bin / name).resolve()
def test_prune_failure_does_not_abort_under_set_e(tmp_path: Path) -> None:
"""A non-removable stale shadow (read-only parent dir) must NOT abort the
caller under ``set -e`` — the #38889 prune-abort hardening."""
lay = _layout(tmp_path)
node_bin = _make_bundled_node(lay.hermes_home)
local_bin = lay.local_bin
local_bin.mkdir(parents=True)
(local_bin / "node").symlink_to(node_bin / "node") # hermes shadow to prune
# Make the stale dir read-only so unlinking the shadow fails with EACCES
# (non-root cannot unlink in a dir without write perm).
local_bin.chmod(0o555)
try:
result = _run_nb_link(tmp_path)
finally:
local_bin.chmod(0o755) # restore so tmp cleanup can proceed
assert result.returncode == 0, (
"set -e abort regression (#38889): a failing best-effort prune must not "
f"fail the caller.\nstdout={result.stdout}\nstderr={result.stderr}"
)
assert "SENTINEL_OK" in result.stdout
def test_install_sh_prune_is_set_e_safe_static() -> None:
"""Static guard for the same fix in install.sh's link_bundled_node (which
can't be sourced standalone): the stale-prune rm must be guarded and the
function must end with `return 0` so it never trips `set -e`."""
text = INSTALL_SH.read_text()
match = re.search(
r"link_bundled_node\(\)\s*\{.*?\n\}",
text,
re.DOTALL,
)
assert match is not None, "could not locate link_bundled_node() in install.sh"
body = match.group(0)
assert 'rm -f "$stale_dir/$name" 2>/dev/null || true' in body, (
"link_bundled_node prune rm must be `2>/dev/null || true` so a failed "
"unlink under `set -e` doesn't abort the installer (#38889)"
)
assert re.search(r"return 0\s*\n\}", body), (
"link_bundled_node must end with `return 0` so a failing prune is never "
"the function's exit status under `set -e` (#38889)"
)