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
1 changed files with 34 additions and 6 deletions

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)