fix(gateway): sync HERMES_HOME before refreshing system systemd units

Under sudo, start/restart refreshed the unit from /root/.hermes before
adopting the unit's pinned home, so TimeoutStopSec and env drifted and
status stayed stuck on "service definition is outdated".
This commit is contained in:
HexLab98 2026-07-08 22:49:57 +07:00 committed by kshitij
parent 55dbc3ffb5
commit cbf685356d

View file

@ -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)