diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index ab743b281f19..d40a9597028c 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -938,6 +938,32 @@ def _read_systemd_unit_environment(system: bool = False) -> dict[str, str]: return parsed +def _hermes_home_from_systemd_unit_file(system: bool = False) -> str | None: + """Read ``HERMES_HOME`` from the on-disk unit file (not ``systemctl show``). + + Prefer the file when refreshing/comparing: under ``sudo``, ``systemctl`` + may be slow/unavailable in tests, and the on-disk unit is what + ``systemd_unit_is_current`` / ``refresh_systemd_unit_if_needed`` already + compare against. + """ + unit_path = get_systemd_unit_path(system=system) + if not unit_path.exists(): + return None + try: + text = unit_path.read_text(encoding="utf-8") + except OSError: + return None + for line in text.splitlines(): + stripped = line.strip() + if not stripped.startswith("Environment="): + continue + body = stripped[len("Environment=") :].strip().strip('"') + if body.startswith("HERMES_HOME="): + value = body.split("=", 1)[1].strip().strip('"') + return value or None + return None + + def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """When acting on a system-scope unit, adopt its ``HERMES_HOME``. @@ -949,8 +975,11 @@ def _sync_hermes_home_from_systemd_unit(system: bool) -> None: """ if not system: return - env = _read_systemd_unit_environment(system=True) - unit_home = env.get("HERMES_HOME", "").strip() + # Prefer the on-disk unit (source of truth for refresh/compare). Fall + # back to ``systemctl show`` for units that only exist in the manager. + unit_home = (_hermes_home_from_systemd_unit_file(system=True) or "").strip() + if not unit_home: + unit_home = _read_systemd_unit_environment(system=True).get("HERMES_HOME", "").strip() if not unit_home: return current = os.environ.get("HERMES_HOME", "").strip() @@ -2830,6 +2859,13 @@ def systemd_unit_is_current(system: bool = False) -> bool: if not unit_path.exists(): return False + # Under ``sudo hermes gateway … --system``, HERMES_HOME is often stripped + # and falls back to ``/root/.hermes``. Adopt the unit's pinned home first + # so TimeoutStopSec / WorkingDirectory / HERMES_HOME comparisons use the + # real operator config — otherwise start/restart "refresh" rewrites a + # correct unit from root's defaults and ``status`` keeps warning forever. + _sync_hermes_home_from_systemd_unit(system=system) + installed = unit_path.read_text(encoding="utf-8") expected_user = _read_systemd_user_from_unit(unit_path) if system else None expected = generate_systemd_unit(system=system, run_as_user=expected_user) @@ -2907,7 +2943,15 @@ def _refuse_temp_home_service_write(definition: str, kind: str) -> bool: def refresh_systemd_unit_if_needed(system: bool = False) -> bool: """Rewrite the installed systemd unit when the generated definition has changed.""" unit_path = get_systemd_unit_path(system=system) - if not unit_path.exists() or systemd_unit_is_current(system=system): + if not unit_path.exists(): + return False + + # Sync before the current-check / regenerate path so a ``sudo`` shell + # does not bake root's config into a system unit that already pins the + # operator's HERMES_HOME. ``systemd_unit_is_current`` also syncs; doing + # it here keeps the write path safe even if that helper changes. + _sync_hermes_home_from_systemd_unit(system=system) + if systemd_unit_is_current(system=system): return False expected_user = _read_systemd_user_from_unit(unit_path) if system else None @@ -3094,6 +3138,12 @@ def systemd_install( unit_path = get_systemd_unit_path(system=system) scope_flag = " --system" if system else "" + # Existing system units already pin HERMES_HOME; adopt it before any + # current-check / regenerate so ``sudo hermes gateway install --system`` + # cannot "repair" a correct unit from root's config. + if unit_path.exists(): + _sync_hermes_home_from_systemd_unit(system=system) + if unit_path.exists() and not force: if not systemd_unit_is_current(system=system): print( @@ -3184,6 +3234,9 @@ def systemd_start(system: bool = False): # Raises UserSystemdUnavailableError with a remediation message. _preflight_user_systemd() _require_service_installed("start", system=system) + # Adopt the unit's HERMES_HOME before refresh so sudo system starts do not + # rewrite TimeoutStopSec / env from /root/.hermes. + _sync_hermes_home_from_systemd_unit(system=system) refresh_systemd_unit_if_needed(system=system) _run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service started") @@ -3224,8 +3277,11 @@ def systemd_restart(system: bool = False): else: _preflight_user_systemd() _require_service_installed("restart", system=system) - refresh_systemd_unit_if_needed(system=system) + # Sync BEFORE refresh. Under sudo, refreshing first used to regenerate the + # unit from /root/.hermes (wrong drain timeout / env), then sync for PID + # lookup — leaving ``status`` stuck on "service definition is outdated". _sync_hermes_home_from_systemd_unit(system=system) + refresh_systemd_unit_if_needed(system=system) from gateway.status import get_running_pid pid = get_running_pid() or _systemd_main_pid(system=system)