diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 9947b67faf..9cd0a8a9ea 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -94,6 +94,39 @@ def check_info(text: str): print(f" {color('→', Colors.CYAN)} {text}") +def _check_gateway_service_linger(issues: list[str]) -> None: + """Warn when a systemd user gateway service will stop after logout.""" + try: + from hermes_cli.gateway import ( + get_systemd_linger_status, + get_systemd_unit_path, + is_linux, + ) + except Exception as e: + check_warn("Gateway service linger", f"(could not import gateway helpers: {e})") + return + + if not is_linux(): + return + + unit_path = get_systemd_unit_path() + if not unit_path.exists(): + return + + print() + print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) + + linger_enabled, linger_detail = get_systemd_linger_status() + if linger_enabled is True: + check_ok("Systemd linger enabled", "(gateway service survives logout)") + elif linger_enabled is False: + check_warn("Systemd linger disabled", "(gateway may stop after logout)") + check_info("Run: sudo loginctl enable-linger $USER") + issues.append("Enable linger for the gateway user service: sudo loginctl enable-linger $USER") + else: + check_warn("Could not verify systemd linger", f"({linger_detail})") + + def run_doctor(args): """Run diagnostic checks.""" should_fix = getattr(args, 'fix', False) @@ -348,6 +381,8 @@ def run_doctor(args): check_warn(f"~/.hermes/state.db exists but has issues: {e}") else: check_info("~/.hermes/state.db not created yet (will be created on first session)") + + _check_gateway_service_linger(issues) # ========================================================================= # Check: External tools diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1e2002f2a9..0529dbb853 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -122,9 +122,72 @@ def is_windows() -> bool: SERVICE_NAME = "hermes-gateway" SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" + def get_systemd_unit_path() -> Path: return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service" + +def get_systemd_linger_status() -> tuple[bool | None, str]: + """Return whether systemd user lingering is enabled for the current user. + + Returns: + (True, "") when linger is enabled. + (False, "") when linger is disabled. + (None, detail) when the status could not be determined. + """ + if not is_linux(): + return None, "not supported on this platform" + + import shutil + + if not shutil.which("loginctl"): + return None, "loginctl not found" + + username = os.getenv("USER") or os.getenv("LOGNAME") + if not username: + try: + import pwd + username = pwd.getpwuid(os.getuid()).pw_name + except Exception: + return None, "could not determine current user" + + try: + result = subprocess.run( + ["loginctl", "show-user", username, "--property=Linger", "--value"], + capture_output=True, + text=True, + check=False, + ) + except Exception as e: + return None, str(e) + + if result.returncode != 0: + detail = (result.stderr or result.stdout or f"exit {result.returncode}").strip() + return None, detail or "loginctl query failed" + + value = (result.stdout or "").strip().lower() + if value in {"yes", "true", "1"}: + return True, "" + if value in {"no", "false", "0"}: + return False, "" + + rendered = value or "" + return None, f"unexpected loginctl output: {rendered}" + + +def print_systemd_linger_guidance() -> None: + """Print the current linger status and the fix when it is disabled.""" + linger_enabled, linger_detail = get_systemd_linger_status() + if linger_enabled is True: + print("✓ Systemd linger is enabled (service survives logout)") + elif linger_enabled is False: + print("⚠ Systemd linger is disabled (gateway may stop when you log out)") + print(" Run: sudo loginctl enable-linger $USER") + else: + print(f"⚠ Could not verify systemd linger ({linger_detail})") + print(" If you want the gateway user service to survive logout, run:") + print(" sudo loginctl enable-linger $USER") + def get_launchd_plist_path() -> Path: return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist" @@ -211,8 +274,7 @@ def systemd_install(force: bool = False): print(f" hermes gateway status # Check status") print(f" journalctl --user -u {SERVICE_NAME} -f # View logs") print() - print("To enable lingering (keeps running after logout):") - print(" sudo loginctl enable-linger $USER") + print_systemd_linger_guidance() def systemd_uninstall(): subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False) @@ -245,28 +307,38 @@ def systemd_status(deep: bool = False): print("✗ Gateway service is not installed") print(" Run: hermes gateway install") return - + # Show detailed status first subprocess.run( ["systemctl", "--user", "status", SERVICE_NAME, "--no-pager"], capture_output=False ) - + # Check if service is active result = subprocess.run( ["systemctl", "--user", "is-active", SERVICE_NAME], capture_output=True, text=True ) - + status = result.stdout.strip() - + if status == "active": print("✓ Gateway service is running") else: print("✗ Gateway service is stopped") print(" Run: hermes gateway start") - + + if deep: + print_systemd_linger_guidance() + else: + linger_enabled, _ = get_systemd_linger_status() + if linger_enabled is True: + print("✓ Systemd linger is enabled (service survives logout)") + elif linger_enabled is False: + print("⚠ Systemd linger is disabled (gateway may stop when you log out)") + print(" Run: sudo loginctl enable-linger $USER") + if deep: print() print("Recent logs:") diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index f30cf87d24..f91d178117 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -9,6 +9,7 @@ from types import SimpleNamespace import pytest import hermes_cli.doctor as doctor +import hermes_cli.gateway as gateway_cli from hermes_cli import doctor as doctor_mod from hermes_cli.doctor import _has_provider_env_config @@ -101,3 +102,37 @@ def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path): doctor_mod.run_doctor(Namespace(fix=False)) assert seen["interactive"] == "1" + + +def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path, capsys): + unit_path = tmp_path / "hermes-gateway.service" + unit_path.write_text("[Unit]\n") + + monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path) + monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, "")) + + issues = [] + doctor._check_gateway_service_linger(issues) + + out = capsys.readouterr().out + assert "Gateway Service" in out + assert "Systemd linger disabled" in out + assert "loginctl enable-linger" in out + assert issues == [ + "Enable linger for the gateway user service: sudo loginctl enable-linger $USER" + ] + + +def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys): + unit_path = tmp_path / "missing.service" + + monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path) + + issues = [] + doctor._check_gateway_service_linger(issues) + + out = capsys.readouterr().out + assert out == "" + assert issues == [] diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py new file mode 100644 index 0000000000..a39b0c6413 --- /dev/null +++ b/tests/hermes_cli/test_gateway.py @@ -0,0 +1,82 @@ +"""Tests for hermes_cli.gateway.""" + +from types import SimpleNamespace + +import hermes_cli.gateway as gateway + + +class TestSystemdLingerStatus: + def test_reports_enabled(self, monkeypatch): + monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setenv("USER", "alice") + monkeypatch.setattr( + gateway.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="yes\n", stderr=""), + ) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/loginctl") + + assert gateway.get_systemd_linger_status() == (True, "") + + def test_reports_disabled(self, monkeypatch): + monkeypatch.setattr(gateway, "is_linux", lambda: True) + monkeypatch.setenv("USER", "alice") + monkeypatch.setattr( + gateway.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="no\n", stderr=""), + ) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/loginctl") + + assert gateway.get_systemd_linger_status() == (False, "") + + +def test_systemd_status_warns_when_linger_disabled(monkeypatch, tmp_path, capsys): + unit_path = tmp_path / "hermes-gateway.service" + unit_path.write_text("[Unit]\n") + + monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda: unit_path) + monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, "")) + + def fake_run(cmd, capture_output=False, text=False, check=False): + if cmd[:4] == ["systemctl", "--user", "status", gateway.SERVICE_NAME]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + if cmd[:3] == ["systemctl", "--user", "is-active"]: + return SimpleNamespace(returncode=0, stdout="active\n", stderr="") + raise AssertionError(f"Unexpected command: {cmd}") + + monkeypatch.setattr(gateway.subprocess, "run", fake_run) + + gateway.systemd_status(deep=False) + + out = capsys.readouterr().out + assert "Gateway service is running" in out + assert "Systemd linger is disabled" in out + assert "loginctl enable-linger" in out + + +def test_systemd_install_checks_linger_status(monkeypatch, tmp_path, capsys): + unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service" + + monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda: unit_path) + monkeypatch.setattr(gateway, "get_systemd_linger_status", lambda: (False, "")) + + calls = [] + + def fake_run(cmd, check=False, **kwargs): + calls.append((cmd, check)) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway.subprocess, "run", fake_run) + + gateway.systemd_install(force=False) + + out = capsys.readouterr().out + assert unit_path.exists() + assert [cmd for cmd, _ in calls] == [ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", gateway.SERVICE_NAME], + ] + assert "Service installed and enabled" in out + assert "Systemd linger is disabled" in out + assert "loginctl enable-linger" in out