fix(gateway): probe the target user's Node tree when generating a system unit

`_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.
This commit is contained in:
ethernet
2026-08-01 21:24:55 -04:00
parent b13148d354
commit 0c3551849f
+34 -6
View File
@@ -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)