From 0c3551849f0e5fed3c893fcd90478dd52653943f Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 1 Aug 2026 21:24:55 -0400 Subject: [PATCH] fix(gateway): probe the target user's Node tree when generating a system unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_append_node_dir_for_service()` had two bugs, both caught by `test_system_unit_uses_target_user_home_not_calling_user`: 1. It crashed. `iter_hermes_node_dirs()` defaults to the *calling* user's Hermes home, so under sudo it stats `/root/.hermes/node/bin` — which raises `PermissionError` for a non-root caller rather than returning False. An unreadable candidate dir means "skip this rung", not "kill the generator", so the probe now swallows OSError. 2. Worse than the crash: had the stat succeeded, a `--system` unit targeting alice would have baked *root's* managed Node into alice's PATH. The generator now skips the managed-Node rung on the system path and re-runs it after `_hermes_home_for_target_user()` resolves, passing that home explicitly. Entries are prepended so the managed Node still outranks the remapped shell-PATH entries, matching the user-unit ordering. The launchd generator is unaffected — it has no target-user remapping, so the default home is already correct there. --- hermes_cli/gateway.py | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f29c42358a2e5..1852afbd8b8f5 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2749,7 +2749,9 @@ def _systemd_watchdog_service_fields( return "notify", f"NotifyAccess=main\nWatchdogSec={seconds}s\n" -def _append_node_dir_for_service(path_entries: list[str]) -> None: +def _append_node_dir_for_service( + path_entries: list[str], hermes_root: Path | None = None +) -> None: """Add the Node directory a generated service unit should use to *path_entries*. The Hermes-managed Node under ``$HERMES_HOME/node`` goes first when it @@ -2757,16 +2759,27 @@ def _append_node_dir_for_service(path_entries: list[str]) -> None: a service unit is written once and then survives reboots, so resolving a system Node that happens to be ahead on the installing shell's PATH bakes the wrong interpreter in permanently — the exact failure the desktop - backend spawn was fixed for. Managed dirs are profile-scoped - (``get_hermes_home()``), so each profile's unit still names its own Node. + backend spawn was fixed for. Managed dirs are profile-scoped, so each + profile's unit still names its own Node. + + *hermes_root* is the Hermes home the unit will run against. System units + installed via sudo MUST pass the **target user's** home: probing the + default (the calling user's — root's — tree) would bake root's Node into + the target user's unit. The probe swallows OSError: an unreadable + candidate dir (hardened home) means "skip the rung", not "crash the + generator". PATH lookup remains the fallback rung for installs with no managed Node. """ from hermes_constants import iter_hermes_node_dirs - for directory in iter_hermes_node_dirs(): + for directory in iter_hermes_node_dirs(hermes_root): entry = str(directory) - if directory.is_dir() and entry not in path_entries: + try: + present = directory.is_dir() + except OSError: + present = False + if present and entry not in path_entries: path_entries.append(entry) resolved_node = shutil.which("node") @@ -2793,7 +2806,11 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") path_entries = _build_service_path_dirs() - _append_node_dir_for_service(path_entries) + if not system: + # System units append the managed Node dirs later, once the TARGET + # user's Hermes home is known — probing here would stat the calling + # (sudo → root's) tree and bake the wrong user's Node into the unit. + _append_node_dir_for_service(path_entries) common_bin_paths = [ "/usr/local/sbin", @@ -2827,6 +2844,17 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir) venv_dir = _remap_path_for_user(venv_dir, home_dir) path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries] + # Managed Node for the TARGET user's tree (see the skip above): probe + # the remapped hermes_home, not the calling user's. Prepend — the + # managed Node must outrank remapped shell-PATH entries, matching the + # user-unit ordering where it's appended before PATH capture. + _target_node_entries: list[str] = [] + _append_node_dir_for_service( + _target_node_entries, Path(hermes_home) if hermes_home else None + ) + path_entries = [ + e for e in _target_node_entries if e not in path_entries + ] + path_entries path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries)) path_entries.extend(_build_wsl_interop_paths(path_entries)) path_entries.extend(common_bin_paths)