test_gateway_service.py
1 """Tests for gateway service management helpers.""" 2 3 import os 4 import pwd 5 from pathlib import Path 6 from types import SimpleNamespace 7 8 import pytest 9 10 import hermes_cli.gateway as gateway_cli 11 from gateway.restart import ( 12 DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, 13 GATEWAY_SERVICE_RESTART_EXIT_CODE, 14 ) 15 16 17 class TestUserSystemdPrivateSocketPreflight: 18 def test_preflight_accepts_private_socket_without_dbus_bus(self, monkeypatch): 19 monkeypatch.setattr(gateway_cli, "_ensure_user_systemd_env", lambda: None) 20 monkeypatch.setattr(gateway_cli, "_user_dbus_socket_path", lambda: Path("/tmp/missing-bus")) 21 monkeypatch.setattr(gateway_cli, "_user_systemd_private_socket_path", lambda: Path("/tmp/private-socket")) 22 monkeypatch.setattr(Path, "exists", lambda self: str(self) == "/tmp/private-socket") 23 24 gateway_cli._preflight_user_systemd(auto_enable_linger=False) 25 26 def test_wait_for_user_dbus_socket_accepts_private_socket(self, monkeypatch): 27 calls = [] 28 monkeypatch.setattr(gateway_cli, "_ensure_user_systemd_env", lambda: calls.append("env")) 29 monkeypatch.setattr(gateway_cli, "_user_dbus_socket_path", lambda: Path("/tmp/missing-bus")) 30 monkeypatch.setattr(gateway_cli, "_user_systemd_private_socket_path", lambda: Path("/tmp/private-socket")) 31 monkeypatch.setattr(Path, "exists", lambda self: str(self) == "/tmp/private-socket") 32 33 assert gateway_cli._wait_for_user_dbus_socket(timeout=0.1) is True 34 assert calls == ["env"] 35 36 37 class TestSystemdServiceRefresh: 38 def test_systemd_install_repairs_outdated_unit_without_force(self, tmp_path, monkeypatch): 39 unit_path = tmp_path / "hermes-gateway.service" 40 unit_path.write_text("old unit\n", encoding="utf-8") 41 42 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 43 monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") 44 45 calls = [] 46 47 def fake_run(cmd, check=True, **kwargs): 48 calls.append(cmd) 49 return SimpleNamespace(returncode=0, stdout="", stderr="") 50 51 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 52 53 gateway_cli.systemd_install() 54 55 assert unit_path.read_text(encoding="utf-8") == "new unit\n" 56 assert calls[:2] == [ 57 ["systemctl", "--user", "daemon-reload"], 58 ["systemctl", "--user", "enable", gateway_cli.get_service_name()], 59 ] 60 61 def test_systemd_start_refreshes_outdated_unit(self, tmp_path, monkeypatch): 62 unit_path = tmp_path / "hermes-gateway.service" 63 unit_path.write_text("old unit\n", encoding="utf-8") 64 65 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 66 monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") 67 68 calls = [] 69 70 def fake_run(cmd, check=True, **kwargs): 71 calls.append(cmd) 72 return SimpleNamespace(returncode=0, stdout="", stderr="") 73 74 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 75 76 gateway_cli.systemd_start() 77 78 assert unit_path.read_text(encoding="utf-8") == "new unit\n" 79 assert calls[:2] == [ 80 ["systemctl", "--user", "daemon-reload"], 81 ["systemctl", "--user", "start", gateway_cli.get_service_name()], 82 ] 83 84 def test_systemd_restart_refreshes_outdated_unit(self, tmp_path, monkeypatch): 85 unit_path = tmp_path / "hermes-gateway.service" 86 unit_path.write_text("old unit\n", encoding="utf-8") 87 88 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 89 monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") 90 91 calls = [] 92 93 def fake_run(cmd, check=True, **kwargs): 94 calls.append(cmd) 95 return SimpleNamespace(returncode=0, stdout="", stderr="") 96 97 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 98 99 gateway_cli.systemd_restart() 100 101 assert unit_path.read_text(encoding="utf-8") == "new unit\n" 102 assert calls[:4] == [ 103 ["systemctl", "--user", "daemon-reload"], 104 ["systemctl", "--user", "show", gateway_cli.get_service_name(), "--no-pager", "--property", "ActiveState,SubState,Result,ExecMainStatus"], 105 ["systemctl", "--user", "reset-failed", gateway_cli.get_service_name()], 106 ["systemctl", "--user", "reload-or-restart", gateway_cli.get_service_name()], 107 ] 108 109 110 def test_run_gateway_refreshes_outdated_unit_on_boot(self, tmp_path, monkeypatch): 111 """run_gateway() should refresh the systemd unit on boot so that 112 restart settings take effect even when the process was respawned 113 via exit-code-75 (bypassing `hermes gateway restart`).""" 114 unit_path = tmp_path / "hermes-gateway.service" 115 unit_path.write_text("old unit\n", encoding="utf-8") 116 117 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 118 monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") 119 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 120 121 calls = [] 122 123 def fake_run(cmd, check=True, **kwargs): 124 calls.append(cmd) 125 return SimpleNamespace(returncode=0, stdout="", stderr="") 126 127 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 128 129 # Prevent run_gateway from actually starting the gateway 130 async def fake_start_gateway(**kwargs): 131 return True 132 133 monkeypatch.setattr("gateway.run.start_gateway", fake_start_gateway) 134 135 gateway_cli.run_gateway() 136 137 assert unit_path.read_text(encoding="utf-8") == "new unit\n" 138 assert ["systemctl", "--user", "daemon-reload"] in calls 139 140 141 class TestRequireServiceInstalled: 142 def test_exits_with_install_hint_when_unit_missing(self, tmp_path, monkeypatch, capsys): 143 unit_path = tmp_path / "hermes-gateway.service" 144 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 145 146 with pytest.raises(SystemExit) as exc_info: 147 gateway_cli._require_service_installed("start") 148 149 assert exc_info.value.code == 1 150 out = capsys.readouterr().out 151 assert "not installed" in out 152 assert "hermes gateway install" in out 153 154 def test_passes_when_unit_exists(self, tmp_path, monkeypatch): 155 unit_path = tmp_path / "hermes-gateway.service" 156 unit_path.write_text("[Unit]\n", encoding="utf-8") 157 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 158 159 gateway_cli._require_service_installed("start") 160 161 162 class TestGeneratedSystemdUnits: 163 def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self, monkeypatch): 164 monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 60) 165 unit = gateway_cli.generate_systemd_unit(system=False) 166 167 assert "ExecStart=" in unit 168 assert "ExecStop=" not in unit 169 assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit 170 assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit 171 # TimeoutStopSec must exceed the default drain_timeout (60s) so 172 # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup 173 # (tool subprocess kill, adapter disconnect) runs — issue #8202. 174 assert "TimeoutStopSec=90" in unit 175 176 def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch): 177 monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: "/home/test/.nvm/versions/node/v24.14.0/bin/node" if cmd == "node" else None) 178 179 unit = gateway_cli.generate_systemd_unit(system=False) 180 181 assert "/home/test/.nvm/versions/node/v24.14.0/bin" in unit 182 183 def test_user_unit_includes_resolved_bun_directory_in_path(self, monkeypatch): 184 def _which(cmd): 185 if cmd == "node": 186 return None 187 if cmd == "bun": 188 return "/home/test/.bun/bin/bun" 189 return None 190 191 monkeypatch.setattr(gateway_cli.shutil, "which", _which) 192 193 unit = gateway_cli.generate_systemd_unit(system=False) 194 195 assert "/home/test/.bun/bin" in unit 196 197 def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self, monkeypatch): 198 monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 60) 199 unit = gateway_cli.generate_systemd_unit(system=True) 200 201 assert "ExecStart=" in unit 202 assert "ExecStop=" not in unit 203 assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit 204 assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit 205 # TimeoutStopSec must exceed the default drain_timeout (60s) so 206 # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup 207 # (tool subprocess kill, adapter disconnect) runs — issue #8202. 208 assert "TimeoutStopSec=90" in unit 209 assert "WantedBy=multi-user.target" in unit 210 211 212 class TestGatewayStopCleanup: 213 def test_stop_only_kills_current_profile_by_default(self, tmp_path, monkeypatch): 214 """Without --all, stop uses systemd (if available) and does NOT call 215 the global kill_gateway_processes().""" 216 unit_path = tmp_path / "hermes-gateway.service" 217 unit_path.write_text("unit\n", encoding="utf-8") 218 219 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 220 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 221 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 222 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 223 224 service_calls = [] 225 kill_calls = [] 226 227 monkeypatch.setattr(gateway_cli, "systemd_stop", lambda system=False: service_calls.append("stop")) 228 monkeypatch.setattr( 229 gateway_cli, 230 "kill_gateway_processes", 231 lambda force=False, all_profiles=False: kill_calls.append(force) or 2, 232 ) 233 234 gateway_cli.gateway_command(SimpleNamespace(gateway_command="stop")) 235 236 assert service_calls == ["stop"] 237 # Global kill should NOT be called without --all 238 assert kill_calls == [] 239 240 def test_stop_all_sweeps_all_gateway_processes(self, tmp_path, monkeypatch): 241 """With --all, stop uses systemd AND calls the global kill_gateway_processes().""" 242 unit_path = tmp_path / "hermes-gateway.service" 243 unit_path.write_text("unit\n", encoding="utf-8") 244 245 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 246 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 247 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 248 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) 249 250 service_calls = [] 251 kill_calls = [] 252 253 monkeypatch.setattr(gateway_cli, "systemd_stop", lambda system=False: service_calls.append("stop")) 254 monkeypatch.setattr( 255 gateway_cli, 256 "kill_gateway_processes", 257 lambda force=False, all_profiles=False: kill_calls.append(force) or 2, 258 ) 259 260 gateway_cli.gateway_command(SimpleNamespace(gateway_command="stop", **{"all": True})) 261 262 assert service_calls == ["stop"] 263 assert kill_calls == [False] 264 265 266 class TestLaunchdServiceRecovery: 267 def test_get_restart_drain_timeout_prefers_env_then_config_then_default(self, monkeypatch): 268 monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False) 269 monkeypatch.setattr(gateway_cli, "read_raw_config", lambda: {}) 270 271 assert ( 272 gateway_cli._get_restart_drain_timeout() 273 == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT 274 ) 275 276 monkeypatch.setattr( 277 gateway_cli, 278 "read_raw_config", 279 lambda: {"agent": {"restart_drain_timeout": 14}}, 280 ) 281 assert gateway_cli._get_restart_drain_timeout() == 14.0 282 283 monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "9") 284 assert gateway_cli._get_restart_drain_timeout() == 9.0 285 286 monkeypatch.setenv("HERMES_RESTART_DRAIN_TIMEOUT", "invalid") 287 assert ( 288 gateway_cli._get_restart_drain_timeout() 289 == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT 290 ) 291 292 def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch): 293 plist_path = tmp_path / "ai.hermes.gateway.plist" 294 plist_path.write_text("<plist>old content</plist>", encoding="utf-8") 295 296 monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) 297 298 calls = [] 299 300 def fake_run(cmd, check=False, **kwargs): 301 calls.append(cmd) 302 return SimpleNamespace(returncode=0, stdout="", stderr="") 303 304 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 305 306 gateway_cli.launchd_install() 307 308 label = gateway_cli.get_launchd_label() 309 domain = gateway_cli._launchd_domain() 310 assert "--replace" in plist_path.read_text(encoding="utf-8") 311 assert calls[:2] == [ 312 ["launchctl", "bootout", f"{domain}/{label}"], 313 ["launchctl", "bootstrap", domain, str(plist_path)], 314 ] 315 316 def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch): 317 plist_path = tmp_path / "ai.hermes.gateway.plist" 318 plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") 319 label = gateway_cli.get_launchd_label() 320 321 calls = [] 322 domain = gateway_cli._launchd_domain() 323 target = f"{domain}/{label}" 324 325 def fake_run(cmd, check=False, **kwargs): 326 if cmd and cmd[0] == "launchctl": 327 calls.append(cmd) 328 if cmd == ["launchctl", "kickstart", target] and calls.count(cmd) == 1: 329 raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service") 330 return SimpleNamespace(returncode=0, stdout="", stderr="") 331 332 monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) 333 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 334 335 gateway_cli.launchd_start() 336 337 assert calls == [ 338 ["launchctl", "kickstart", target], 339 ["launchctl", "bootstrap", domain, str(plist_path)], 340 ["launchctl", "kickstart", target], 341 ] 342 343 def test_launchd_start_reloads_on_kickstart_exit_code_113(self, tmp_path, monkeypatch): 344 """Exit code 113 (\"Could not find service\") should also trigger bootstrap recovery.""" 345 plist_path = tmp_path / "ai.hermes.gateway.plist" 346 plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8") 347 label = gateway_cli.get_launchd_label() 348 349 calls = [] 350 domain = gateway_cli._launchd_domain() 351 target = f"{domain}/{label}" 352 353 def fake_run(cmd, check=False, **kwargs): 354 if cmd and cmd[0] == "launchctl": 355 calls.append(cmd) 356 if cmd == ["launchctl", "kickstart", target] and calls.count(cmd) == 1: 357 raise gateway_cli.subprocess.CalledProcessError(113, cmd, stderr="Could not find service") 358 return SimpleNamespace(returncode=0, stdout="", stderr="") 359 360 monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) 361 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 362 363 gateway_cli.launchd_start() 364 365 assert calls == [ 366 ["launchctl", "kickstart", target], 367 ["launchctl", "bootstrap", domain, str(plist_path)], 368 ["launchctl", "kickstart", target], 369 ] 370 371 def test_launchd_restart_drains_running_gateway_before_kickstart(self, monkeypatch): 372 calls = [] 373 target = f"{gateway_cli._launchd_domain()}/{gateway_cli.get_launchd_label()}" 374 375 monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) 376 monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) 377 monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda timeout, force_after=None: True) 378 monkeypatch.setattr(gateway_cli, "terminate_pid", lambda pid, force=False: calls.append(("term", pid, force))) 379 monkeypatch.setattr( 380 "gateway.status.get_running_pid", 381 lambda: 321, 382 ) 383 384 def fake_run(cmd, check=False, **kwargs): 385 calls.append(cmd) 386 return SimpleNamespace(returncode=0, stdout="", stderr="") 387 388 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 389 390 gateway_cli.launchd_restart() 391 392 assert calls == [ 393 ("term", 321, False), 394 ["launchctl", "kickstart", "-k", target], 395 ] 396 397 def test_launchd_restart_self_requests_graceful_restart_without_kickstart(self, monkeypatch, capsys): 398 calls = [] 399 400 monkeypatch.setattr( 401 "gateway.status.get_running_pid", 402 lambda: 321, 403 ) 404 monkeypatch.setattr( 405 gateway_cli, 406 "_request_gateway_self_restart", 407 lambda pid: calls.append(("self", pid)) or True, 408 ) 409 monkeypatch.setattr( 410 gateway_cli.subprocess, 411 "run", 412 lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("launchctl should not run")), 413 ) 414 415 gateway_cli.launchd_restart() 416 417 assert calls == [("self", 321)] 418 assert "restart requested" in capsys.readouterr().out.lower() 419 420 def test_launchd_stop_uses_bootout_not_kill(self, monkeypatch): 421 """launchd_stop must bootout the service so KeepAlive doesn't respawn it.""" 422 label = gateway_cli.get_launchd_label() 423 domain = gateway_cli._launchd_domain() 424 target = f"{domain}/{label}" 425 426 calls = [] 427 428 def fake_run(cmd, check=False, **kwargs): 429 calls.append(cmd) 430 return SimpleNamespace(returncode=0, stdout="", stderr="") 431 432 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 433 monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda **kw: None) 434 435 gateway_cli.launchd_stop() 436 437 assert calls == [["launchctl", "bootout", target]] 438 439 def test_launchd_stop_tolerates_already_unloaded(self, monkeypatch, capsys): 440 """launchd_stop silently handles exit codes 3/113 (job not loaded).""" 441 label = gateway_cli.get_launchd_label() 442 domain = gateway_cli._launchd_domain() 443 target = f"{domain}/{label}" 444 445 def fake_run(cmd, check=False, **kwargs): 446 if "bootout" in cmd: 447 raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service") 448 return SimpleNamespace(returncode=0, stdout="", stderr="") 449 450 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 451 monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", lambda **kw: None) 452 453 # Should not raise — exit code 3 means already unloaded 454 gateway_cli.launchd_stop() 455 456 output = capsys.readouterr().out 457 assert "stopped" in output.lower() 458 459 def test_launchd_stop_waits_for_process_exit(self, monkeypatch): 460 """launchd_stop calls _wait_for_gateway_exit after bootout.""" 461 wait_called = [] 462 463 def fake_run(cmd, check=False, **kwargs): 464 return SimpleNamespace(returncode=0, stdout="", stderr="") 465 466 def fake_wait(**kwargs): 467 wait_called.append(kwargs) 468 469 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 470 monkeypatch.setattr(gateway_cli, "_wait_for_gateway_exit", fake_wait) 471 472 gateway_cli.launchd_stop() 473 474 assert len(wait_called) == 1 475 assert wait_called[0] == {"timeout": 10.0, "force_after": 5.0} 476 477 def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys): 478 plist_path = tmp_path / "ai.hermes.gateway.plist" 479 plist_path.write_text("<plist>old content</plist>", encoding="utf-8") 480 481 monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) 482 monkeypatch.setattr( 483 gateway_cli.subprocess, 484 "run", 485 lambda *args, **kwargs: SimpleNamespace(returncode=113, stdout="", stderr="Could not find service"), 486 ) 487 488 gateway_cli.launchd_status() 489 490 output = capsys.readouterr().out 491 assert str(plist_path) in output 492 assert "stale" in output.lower() 493 assert "not loaded" in output.lower() 494 495 496 class TestGatewayServiceDetection: 497 def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch): 498 monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) 499 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 500 monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: None) 501 502 assert gateway_cli.supports_systemd_services() is False 503 504 def test_supports_systemd_services_returns_true_when_systemctl_present(self, monkeypatch): 505 monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) 506 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 507 monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False) 508 monkeypatch.setattr(gateway_cli, "is_container", lambda: False) 509 monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: "/usr/bin/systemctl") 510 511 assert gateway_cli.supports_systemd_services() is True 512 513 def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch): 514 user_unit = SimpleNamespace(exists=lambda: True) 515 system_unit = SimpleNamespace(exists=lambda: True) 516 517 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 518 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 519 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 520 monkeypatch.setattr( 521 gateway_cli, 522 "get_systemd_unit_path", 523 lambda system=False: system_unit if system else user_unit, 524 ) 525 526 def fake_run(cmd, capture_output=True, text=True, **kwargs): 527 if cmd == ["systemctl", "--user", "is-active", gateway_cli.get_service_name()]: 528 return SimpleNamespace(returncode=0, stdout="inactive\n", stderr="") 529 if cmd == ["systemctl", "is-active", gateway_cli.get_service_name()]: 530 return SimpleNamespace(returncode=0, stdout="active\n", stderr="") 531 raise AssertionError(f"Unexpected command: {cmd}") 532 533 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 534 535 assert gateway_cli._is_service_running() is True 536 537 def test_is_service_running_returns_false_when_systemctl_missing(self, monkeypatch): 538 unit = SimpleNamespace(exists=lambda: True) 539 540 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 541 monkeypatch.setattr( 542 gateway_cli, 543 "get_systemd_unit_path", 544 lambda system=False: unit, 545 ) 546 547 def fake_run(*args, **kwargs): 548 raise FileNotFoundError("systemctl") 549 550 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 551 552 assert gateway_cli._is_service_running() is False 553 554 class TestGatewaySystemServiceRouting: 555 def test_systemd_restart_self_requests_graceful_restart_and_waits(self, monkeypatch, capsys): 556 calls = [] 557 558 monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) 559 monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) 560 monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system))) 561 monkeypatch.setattr( 562 "gateway.status.get_running_pid", 563 lambda: 654, 564 ) 565 monkeypatch.setattr( 566 gateway_cli, 567 "_request_gateway_self_restart", 568 lambda pid: calls.append(("self", pid)) or True, 569 ) 570 571 # Simulate: old process dies immediately, new process becomes active 572 kill_call_count = [0] 573 def fake_kill(pid, sig): 574 kill_call_count[0] += 1 575 if kill_call_count[0] >= 2: # first call checks, second = dead 576 raise ProcessLookupError() 577 monkeypatch.setattr(os, "kill", fake_kill) 578 579 # Simulate systemctl reset-failed/start followed by an active unit 580 new_pid = [None] 581 def fake_subprocess_run(cmd, **kwargs): 582 if "reset-failed" in cmd: 583 calls.append(("reset-failed", cmd)) 584 return SimpleNamespace(stdout="", returncode=0) 585 if "start" in cmd: 586 calls.append(("start", cmd)) 587 return SimpleNamespace(stdout="", returncode=0) 588 if "show" in cmd: 589 new_pid[0] = 999 590 return SimpleNamespace( 591 stdout="ActiveState=active\nSubState=running\nResult=success\nExecMainStatus=0\n", 592 returncode=0, 593 ) 594 raise AssertionError(f"Unexpected systemctl call: {cmd}") 595 596 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_subprocess_run) 597 # get_running_pid returns new PID after restart 598 pid_calls = [0] 599 def fake_get_pid(): 600 pid_calls[0] += 1 601 return 999 if pid_calls[0] > 1 else 654 602 monkeypatch.setattr("gateway.status.get_running_pid", fake_get_pid) 603 604 gateway_cli.systemd_restart() 605 606 assert ("self", 654) in calls 607 assert any(call[0] == "reset-failed" for call in calls) 608 assert any(call[0] == "start" for call in calls) 609 out = capsys.readouterr().out.lower() 610 assert "restarted" in out 611 612 def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys): 613 monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) 614 monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) 615 monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) 616 monkeypatch.setattr( 617 "gateway.status.read_runtime_status", 618 lambda: {"restart_requested": True, "gateway_state": "stopped"}, 619 ) 620 monkeypatch.setattr(gateway_cli, "_request_gateway_self_restart", lambda pid: False) 621 622 calls = [] 623 started = {"value": False} 624 625 def fake_subprocess_run(cmd, **kwargs): 626 if "show" in cmd: 627 if not started["value"]: 628 return SimpleNamespace( 629 stdout=( 630 "ActiveState=failed\n" 631 "SubState=failed\n" 632 "Result=exit-code\n" 633 f"ExecMainStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}\n" 634 ), 635 returncode=0, 636 ) 637 return SimpleNamespace( 638 stdout="ActiveState=active\nSubState=running\nResult=success\nExecMainStatus=0\n", 639 returncode=0, 640 ) 641 if "reset-failed" in cmd: 642 calls.append(("reset-failed", cmd)) 643 return SimpleNamespace(stdout="", returncode=0) 644 if "start" in cmd: 645 started["value"] = True 646 calls.append(("start", cmd)) 647 return SimpleNamespace(stdout="", returncode=0) 648 raise AssertionError(f"Unexpected command: {cmd}") 649 650 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_subprocess_run) 651 monkeypatch.setattr( 652 "gateway.status.get_running_pid", 653 lambda: 999 if started["value"] else None, 654 ) 655 656 gateway_cli.systemd_restart() 657 658 assert any(call[0] == "reset-failed" for call in calls) 659 assert any(call[0] == "start" for call in calls) 660 out = capsys.readouterr().out.lower() 661 assert "restarted" in out 662 663 def test_systemd_status_surfaces_planned_restart_failure(self, monkeypatch, capsys): 664 unit = SimpleNamespace(exists=lambda: True) 665 monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) 666 monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit) 667 monkeypatch.setattr(gateway_cli, "has_conflicting_systemd_units", lambda: False) 668 monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: False) 669 monkeypatch.setattr(gateway_cli, "systemd_unit_is_current", lambda system=False: True) 670 monkeypatch.setattr(gateway_cli, "_runtime_health_lines", lambda: ["⚠ Last shutdown reason: Gateway restart requested"]) 671 monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (True, "")) 672 monkeypatch.setattr(gateway_cli, "_read_systemd_unit_properties", lambda system=False: { 673 "ActiveState": "failed", 674 "SubState": "failed", 675 "Result": "exit-code", 676 "ExecMainStatus": str(GATEWAY_SERVICE_RESTART_EXIT_CODE), 677 }) 678 679 calls = [] 680 681 def fake_run_systemctl(args, **kwargs): 682 calls.append(args) 683 if args[:2] == ["status", gateway_cli.get_service_name()]: 684 return SimpleNamespace(returncode=0, stdout="", stderr="") 685 if args[:2] == ["is-active", gateway_cli.get_service_name()]: 686 return SimpleNamespace(returncode=3, stdout="failed\n", stderr="") 687 raise AssertionError(f"Unexpected args: {args}") 688 689 monkeypatch.setattr(gateway_cli, "_run_systemctl", fake_run_systemctl) 690 691 gateway_cli.systemd_status() 692 693 out = capsys.readouterr().out 694 assert "Planned restart is stuck in systemd failed state" in out 695 696 def test_gateway_status_dispatches_full_flag(self, monkeypatch): 697 user_unit = SimpleNamespace(exists=lambda: True) 698 system_unit = SimpleNamespace(exists=lambda: False) 699 700 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 701 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 702 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 703 monkeypatch.setattr( 704 gateway_cli, 705 "get_systemd_unit_path", 706 lambda system=False: system_unit if system else user_unit, 707 ) 708 monkeypatch.setattr( 709 gateway_cli, 710 "get_gateway_runtime_snapshot", 711 lambda system=False: gateway_cli.GatewayRuntimeSnapshot( 712 manager="systemd (user)", 713 service_installed=True, 714 service_running=False, 715 gateway_pids=(), 716 service_scope="user", 717 ), 718 ) 719 720 calls = [] 721 monkeypatch.setattr( 722 gateway_cli, 723 "systemd_status", 724 lambda deep=False, system=False, full=False: calls.append((deep, system, full)), 725 ) 726 727 gateway_cli.gateway_command( 728 SimpleNamespace(gateway_command="status", deep=False, system=False, full=True) 729 ) 730 731 assert calls == [(False, False, True)] 732 733 def test_gateway_install_passes_system_flags(self, monkeypatch): 734 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 735 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 736 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 737 738 calls = [] 739 monkeypatch.setattr( 740 gateway_cli, 741 "systemd_install", 742 lambda force=False, system=False, run_as_user=None: calls.append((force, system, run_as_user)), 743 ) 744 745 gateway_cli.gateway_command( 746 SimpleNamespace(gateway_command="install", force=True, system=True, run_as_user="alice") 747 ) 748 749 assert calls == [(True, True, "alice")] 750 751 def test_gateway_install_reports_termux_manual_mode(self, monkeypatch, capsys): 752 monkeypatch.setattr(gateway_cli, "is_termux", lambda: True) 753 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) 754 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 755 756 try: 757 gateway_cli.gateway_command( 758 SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None) 759 ) 760 except SystemExit as exc: 761 assert exc.code == 1 762 else: 763 raise AssertionError("Expected gateway_command to exit on unsupported Termux service install") 764 765 out = capsys.readouterr().out 766 assert "not supported on Termux" in out 767 assert "Run manually: hermes gateway" in out 768 769 def test_gateway_status_prefers_system_service_when_only_system_unit_exists(self, monkeypatch): 770 user_unit = SimpleNamespace(exists=lambda: False) 771 system_unit = SimpleNamespace(exists=lambda: True) 772 773 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 774 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 775 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 776 monkeypatch.setattr( 777 gateway_cli, 778 "get_systemd_unit_path", 779 lambda system=False: system_unit if system else user_unit, 780 ) 781 782 calls = [] 783 monkeypatch.setattr( 784 gateway_cli, 785 "systemd_status", 786 lambda deep=False, system=False, full=False: calls.append((deep, system, full)), 787 ) 788 789 gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False)) 790 791 assert calls == [(False, False, False)] 792 793 def test_gateway_status_reports_manual_process_when_service_is_stopped(self, monkeypatch, capsys): 794 user_unit = SimpleNamespace(exists=lambda: True) 795 system_unit = SimpleNamespace(exists=lambda: False) 796 797 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 798 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 799 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 800 monkeypatch.setattr( 801 gateway_cli, 802 "get_systemd_unit_path", 803 lambda system=False: system_unit if system else user_unit, 804 ) 805 monkeypatch.setattr( 806 gateway_cli, 807 "systemd_status", 808 lambda deep=False, system=False, full=False: print("service stopped"), 809 ) 810 monkeypatch.setattr( 811 gateway_cli, 812 "get_gateway_runtime_snapshot", 813 lambda system=False: gateway_cli.GatewayRuntimeSnapshot( 814 manager="systemd (user)", 815 service_installed=True, 816 service_running=False, 817 gateway_pids=(4321,), 818 service_scope="user", 819 ), 820 ) 821 822 gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False)) 823 824 out = capsys.readouterr().out 825 assert "service stopped" in out 826 assert "Gateway process is running for this profile" in out 827 assert "PID(s): 4321" in out 828 829 def test_gateway_status_on_termux_shows_manual_guidance(self, monkeypatch, capsys): 830 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) 831 monkeypatch.setattr(gateway_cli, "is_termux", lambda: True) 832 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 833 monkeypatch.setattr(gateway_cli, "find_gateway_pids", lambda exclude_pids=None: []) 834 monkeypatch.setattr(gateway_cli, "_runtime_health_lines", lambda: []) 835 836 gateway_cli.gateway_command(SimpleNamespace(gateway_command="status", deep=False, system=False)) 837 838 out = capsys.readouterr().out 839 assert "Gateway is not running" in out 840 assert "nohup hermes gateway" in out 841 assert "install as user service" not in out 842 843 def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch): 844 plist_path = tmp_path / "ai.hermes.gateway.plist" 845 plist_path.write_text("plist\n", encoding="utf-8") 846 847 monkeypatch.setattr(gateway_cli, "is_linux", lambda: False) 848 monkeypatch.setattr(gateway_cli, "is_macos", lambda: True) 849 monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) 850 monkeypatch.setattr( 851 gateway_cli, 852 "launchd_restart", 853 lambda: (_ for _ in ()).throw( 854 gateway_cli.subprocess.CalledProcessError(5, ["launchctl", "kickstart", "-k", "gui/501/ai.hermes.gateway"]) 855 ), 856 ) 857 858 run_calls = [] 859 monkeypatch.setattr(gateway_cli, "run_gateway", lambda verbose=0, quiet=False, replace=False: run_calls.append((verbose, quiet, replace))) 860 monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda force=False: 0) 861 862 try: 863 gateway_cli.gateway_command(SimpleNamespace(gateway_command="restart", system=False)) 864 except SystemExit as exc: 865 assert exc.code == 1 866 else: 867 raise AssertionError("Expected gateway_command to exit when service restart fails") 868 869 assert run_calls == [] 870 871 872 class TestDetectVenvDir: 873 """Tests for _detect_venv_dir() virtualenv detection.""" 874 875 def test_detects_active_virtualenv_via_sys_prefix(self, tmp_path, monkeypatch): 876 venv_path = tmp_path / "my-custom-venv" 877 venv_path.mkdir() 878 monkeypatch.setattr("sys.prefix", str(venv_path)) 879 monkeypatch.setattr("sys.base_prefix", "/usr") 880 881 result = gateway_cli._detect_venv_dir() 882 assert result == venv_path 883 884 def test_falls_back_to_dot_venv_directory(self, tmp_path, monkeypatch): 885 # Not inside a virtualenv 886 monkeypatch.setattr("sys.prefix", "/usr") 887 monkeypatch.setattr("sys.base_prefix", "/usr") 888 monkeypatch.delenv("VIRTUAL_ENV", raising=False) 889 monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) 890 891 dot_venv = tmp_path / ".venv" 892 dot_venv.mkdir() 893 894 result = gateway_cli._detect_venv_dir() 895 assert result == dot_venv 896 897 def test_falls_back_to_venv_directory(self, tmp_path, monkeypatch): 898 monkeypatch.setattr("sys.prefix", "/usr") 899 monkeypatch.setattr("sys.base_prefix", "/usr") 900 monkeypatch.delenv("VIRTUAL_ENV", raising=False) 901 monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) 902 903 venv = tmp_path / "venv" 904 venv.mkdir() 905 906 result = gateway_cli._detect_venv_dir() 907 assert result == venv 908 909 def test_prefers_dot_venv_over_venv(self, tmp_path, monkeypatch): 910 monkeypatch.setattr("sys.prefix", "/usr") 911 monkeypatch.setattr("sys.base_prefix", "/usr") 912 monkeypatch.delenv("VIRTUAL_ENV", raising=False) 913 monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) 914 915 (tmp_path / ".venv").mkdir() 916 (tmp_path / "venv").mkdir() 917 918 result = gateway_cli._detect_venv_dir() 919 assert result == tmp_path / ".venv" 920 921 def test_returns_none_when_no_virtualenv(self, tmp_path, monkeypatch): 922 monkeypatch.setattr("sys.prefix", "/usr") 923 monkeypatch.setattr("sys.base_prefix", "/usr") 924 monkeypatch.delenv("VIRTUAL_ENV", raising=False) 925 monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", tmp_path) 926 927 result = gateway_cli._detect_venv_dir() 928 assert result is None 929 930 931 class TestSystemUnitHermesHome: 932 """HERMES_HOME in system units must reference the target user, not root.""" 933 934 def test_system_unit_uses_target_user_home_not_calling_user(self, monkeypatch): 935 # Simulate sudo: Path.home() returns /root, target user is alice 936 monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) 937 monkeypatch.delenv("HERMES_HOME", raising=False) 938 monkeypatch.setattr( 939 gateway_cli, "_system_service_identity", 940 lambda run_as_user=None: ("alice", "alice", "/home/alice"), 941 ) 942 monkeypatch.setattr( 943 gateway_cli, "_build_user_local_paths", 944 lambda home, existing: [], 945 ) 946 947 unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice") 948 949 assert 'HERMES_HOME=/home/alice/.hermes' in unit 950 assert '/root/.hermes' not in unit 951 952 def test_system_unit_remaps_profile_to_target_user(self, monkeypatch): 953 # Simulate sudo with a profile: HERMES_HOME was resolved under root 954 monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) 955 monkeypatch.setenv("HERMES_HOME", "/root/.hermes/profiles/coder") 956 monkeypatch.setattr( 957 gateway_cli, "_system_service_identity", 958 lambda run_as_user=None: ("alice", "alice", "/home/alice"), 959 ) 960 monkeypatch.setattr( 961 gateway_cli, "_build_user_local_paths", 962 lambda home, existing: [], 963 ) 964 965 unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice") 966 967 assert 'HERMES_HOME=/home/alice/.hermes/profiles/coder' in unit 968 assert '/root/' not in unit 969 970 def test_system_unit_preserves_custom_hermes_home(self, monkeypatch): 971 # Custom HERMES_HOME not under any user's home — keep as-is 972 monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) 973 monkeypatch.setenv("HERMES_HOME", "/opt/hermes-shared") 974 monkeypatch.setattr( 975 gateway_cli, "_system_service_identity", 976 lambda run_as_user=None: ("alice", "alice", "/home/alice"), 977 ) 978 monkeypatch.setattr( 979 gateway_cli, "_build_user_local_paths", 980 lambda home, existing: [], 981 ) 982 983 unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice") 984 985 assert 'HERMES_HOME=/opt/hermes-shared' in unit 986 987 def test_user_unit_unaffected_by_change(self): 988 # User-scope units should still use the calling user's HERMES_HOME 989 unit = gateway_cli.generate_systemd_unit(system=False) 990 991 hermes_home = str(gateway_cli.get_hermes_home().resolve()) 992 assert f'HERMES_HOME={hermes_home}' in unit 993 994 995 class TestHermesHomeForTargetUser: 996 """Unit tests for _hermes_home_for_target_user().""" 997 998 def test_remaps_default_home(self, monkeypatch): 999 monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) 1000 monkeypatch.delenv("HERMES_HOME", raising=False) 1001 1002 result = gateway_cli._hermes_home_for_target_user("/home/alice") 1003 assert result == "/home/alice/.hermes" 1004 1005 def test_remaps_profile_path(self, monkeypatch): 1006 monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) 1007 monkeypatch.setenv("HERMES_HOME", "/root/.hermes/profiles/coder") 1008 1009 result = gateway_cli._hermes_home_for_target_user("/home/alice") 1010 assert result == "/home/alice/.hermes/profiles/coder" 1011 1012 def test_keeps_custom_path(self, monkeypatch): 1013 monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/root"))) 1014 monkeypatch.setenv("HERMES_HOME", "/opt/hermes") 1015 1016 result = gateway_cli._hermes_home_for_target_user("/home/alice") 1017 assert result == "/opt/hermes" 1018 1019 def test_noop_when_same_user(self, monkeypatch): 1020 monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/home/alice"))) 1021 monkeypatch.delenv("HERMES_HOME", raising=False) 1022 1023 result = gateway_cli._hermes_home_for_target_user("/home/alice") 1024 assert result == "/home/alice/.hermes" 1025 1026 1027 class TestGeneratedUnitUsesDetectedVenv: 1028 def test_systemd_unit_uses_dot_venv_when_detected(self, tmp_path, monkeypatch): 1029 dot_venv = tmp_path / ".venv" 1030 dot_venv.mkdir() 1031 (dot_venv / "bin").mkdir() 1032 1033 monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: dot_venv) 1034 monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(dot_venv / "bin" / "python")) 1035 1036 unit = gateway_cli.generate_systemd_unit(system=False) 1037 1038 assert f"VIRTUAL_ENV={dot_venv}" in unit 1039 assert f"{dot_venv}/bin" in unit 1040 # Must NOT contain a hardcoded /venv/ path 1041 assert "/venv/" not in unit or "/.venv/" in unit 1042 1043 1044 class TestGeneratedUnitIncludesLocalBin: 1045 """~/.local/bin must be in PATH so uvx/pipx tools are discoverable.""" 1046 1047 def test_user_unit_includes_local_bin_in_path(self, monkeypatch): 1048 home = Path.home() 1049 monkeypatch.setattr( 1050 gateway_cli, 1051 "_build_user_local_paths", 1052 lambda home_path, existing: [str(home / ".local" / "bin")], 1053 ) 1054 unit = gateway_cli.generate_systemd_unit(system=False) 1055 assert f"{home}/.local/bin" in unit 1056 1057 def test_system_unit_includes_local_bin_in_path(self, monkeypatch): 1058 monkeypatch.setattr( 1059 gateway_cli, 1060 "_build_user_local_paths", 1061 lambda home_path, existing: [str(home_path / ".local" / "bin")], 1062 ) 1063 unit = gateway_cli.generate_systemd_unit(system=True) 1064 # System unit uses the resolved home dir from _system_service_identity 1065 assert "/.local/bin" in unit 1066 1067 1068 class TestSystemServiceIdentityRootHandling: 1069 """Root user handling in _system_service_identity().""" 1070 1071 def test_auto_detected_root_is_rejected(self, monkeypatch): 1072 """When root is auto-detected (not explicitly requested), raise.""" 1073 import pwd 1074 import grp 1075 1076 monkeypatch.delenv("SUDO_USER", raising=False) 1077 monkeypatch.setenv("USER", "root") 1078 monkeypatch.setenv("LOGNAME", "root") 1079 1080 import pytest 1081 with pytest.raises(ValueError, match="pass --run-as-user root to override"): 1082 gateway_cli._system_service_identity(run_as_user=None) 1083 1084 def test_explicit_root_is_allowed(self, monkeypatch): 1085 """When root is explicitly passed via --run-as-user root, allow it.""" 1086 import pwd 1087 import grp 1088 1089 root_info = pwd.getpwnam("root") 1090 root_group = grp.getgrgid(root_info.pw_gid).gr_name 1091 1092 username, group, home = gateway_cli._system_service_identity(run_as_user="root") 1093 assert username == "root" 1094 assert home == root_info.pw_dir 1095 1096 def test_non_root_user_passes_through(self, monkeypatch): 1097 """Normal non-root user works as before.""" 1098 import pwd 1099 import grp 1100 1101 monkeypatch.delenv("SUDO_USER", raising=False) 1102 monkeypatch.setenv("USER", "nobody") 1103 monkeypatch.setenv("LOGNAME", "nobody") 1104 1105 try: 1106 username, group, home = gateway_cli._system_service_identity(run_as_user=None) 1107 assert username == "nobody" 1108 except ValueError as e: 1109 # "nobody" might not exist on all systems 1110 assert "Unknown user" in str(e) 1111 1112 1113 class TestEnsureUserSystemdEnv: 1114 """Tests for _ensure_user_systemd_env() D-Bus session bus auto-detection.""" 1115 1116 def test_sets_xdg_runtime_dir_when_missing(self, tmp_path, monkeypatch): 1117 monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False) 1118 monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) 1119 monkeypatch.setattr(os, "getuid", lambda: 42) 1120 1121 # Patch Path.exists so /run/user/42 appears to exist. 1122 # Using a FakePath subclass breaks on Python 3.12+ where 1123 # PosixPath.__new__ ignores the redirected path argument. 1124 _orig_exists = gateway_cli.Path.exists 1125 monkeypatch.setattr( 1126 gateway_cli.Path, "exists", 1127 lambda self: True if str(self) == "/run/user/42" else _orig_exists(self), 1128 ) 1129 1130 gateway_cli._ensure_user_systemd_env() 1131 1132 assert os.environ.get("XDG_RUNTIME_DIR") == "/run/user/42" 1133 1134 def test_sets_dbus_address_when_bus_socket_exists(self, tmp_path, monkeypatch): 1135 runtime = tmp_path / "runtime" 1136 runtime.mkdir() 1137 bus_socket = runtime / "bus" 1138 bus_socket.touch() # simulate the socket file 1139 1140 monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime)) 1141 monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) 1142 monkeypatch.setattr(os, "getuid", lambda: 99) 1143 1144 gateway_cli._ensure_user_systemd_env() 1145 1146 assert os.environ["DBUS_SESSION_BUS_ADDRESS"] == f"unix:path={bus_socket}" 1147 1148 def test_preserves_existing_env_vars(self, monkeypatch): 1149 monkeypatch.setenv("XDG_RUNTIME_DIR", "/custom/runtime") 1150 monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/custom/bus") 1151 1152 gateway_cli._ensure_user_systemd_env() 1153 1154 assert os.environ["XDG_RUNTIME_DIR"] == "/custom/runtime" 1155 assert os.environ["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/custom/bus" 1156 1157 def test_no_dbus_when_bus_socket_missing(self, tmp_path, monkeypatch): 1158 runtime = tmp_path / "runtime" 1159 runtime.mkdir() 1160 # no bus socket created 1161 1162 monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime)) 1163 monkeypatch.delenv("DBUS_SESSION_BUS_ADDRESS", raising=False) 1164 monkeypatch.setattr(os, "getuid", lambda: 99) 1165 1166 gateway_cli._ensure_user_systemd_env() 1167 1168 assert "DBUS_SESSION_BUS_ADDRESS" not in os.environ 1169 1170 def test_systemctl_cmd_calls_ensure_for_user_mode(self, monkeypatch): 1171 calls = [] 1172 monkeypatch.setattr(gateway_cli, "_ensure_user_systemd_env", lambda: calls.append("called")) 1173 1174 result = gateway_cli._systemctl_cmd(system=False) 1175 assert result == ["systemctl", "--user"] 1176 assert calls == ["called"] 1177 1178 def test_systemctl_cmd_skips_ensure_for_system_mode(self, monkeypatch): 1179 calls = [] 1180 monkeypatch.setattr(gateway_cli, "_ensure_user_systemd_env", lambda: calls.append("called")) 1181 1182 result = gateway_cli._systemctl_cmd(system=True) 1183 assert result == ["systemctl"] 1184 assert calls == [] 1185 1186 1187 class TestPreflightUserSystemd: 1188 """Tests for _preflight_user_systemd() — D-Bus reachability before systemctl --user. 1189 1190 Covers issue #5130 / Rick's RHEL 9.6 SSH scenario: setup tries to start the 1191 gateway via ``systemctl --user start`` in a shell with no user D-Bus session, 1192 which previously failed with a raw ``CalledProcessError`` and no remediation. 1193 """ 1194 1195 def test_noop_when_bus_socket_exists(self, monkeypatch): 1196 """Socket already there (desktop / linger + prior login) → no-op.""" 1197 monkeypatch.setattr( 1198 gateway_cli, "_user_dbus_socket_path", 1199 lambda: type("P", (), {"exists": lambda self: True})(), 1200 ) 1201 monkeypatch.setattr( 1202 gateway_cli, "_user_systemd_private_socket_path", 1203 lambda: type("P", (), {"exists": lambda self: False})(), 1204 ) 1205 # Should not raise, no subprocess calls needed. 1206 gateway_cli._preflight_user_systemd() 1207 1208 def test_raises_when_linger_disabled_and_loginctl_denied(self, monkeypatch): 1209 """Rick's scenario: no D-Bus, no linger, non-root SSH → clear error.""" 1210 monkeypatch.setattr( 1211 gateway_cli, "_user_dbus_socket_path", 1212 lambda: type("P", (), {"exists": lambda self: False})(), 1213 ) 1214 monkeypatch.setattr( 1215 gateway_cli, "_user_systemd_private_socket_path", 1216 lambda: type("P", (), {"exists": lambda self: False})(), 1217 ) 1218 monkeypatch.setattr( 1219 gateway_cli, "get_systemd_linger_status", lambda: (False, ""), 1220 ) 1221 monkeypatch.setattr(gateway_cli.shutil, "which", lambda _: "/usr/bin/loginctl") 1222 1223 class _Result: 1224 returncode = 1 1225 stdout = "" 1226 stderr = "Interactive authentication required." 1227 1228 monkeypatch.setattr( 1229 gateway_cli.subprocess, "run", lambda *a, **kw: _Result(), 1230 ) 1231 1232 with pytest.raises(gateway_cli.UserSystemdUnavailableError) as exc_info: 1233 gateway_cli._preflight_user_systemd() 1234 1235 msg = str(exc_info.value) 1236 assert "sudo loginctl enable-linger" in msg 1237 assert "hermes gateway run" in msg # foreground fallback mentioned 1238 assert "Interactive authentication required" in msg 1239 1240 def test_raises_when_loginctl_missing(self, monkeypatch): 1241 """No loginctl binary at all → suggest sudo install + manual fix.""" 1242 monkeypatch.setattr( 1243 gateway_cli, "_user_dbus_socket_path", 1244 lambda: type("P", (), {"exists": lambda self: False})(), 1245 ) 1246 monkeypatch.setattr( 1247 gateway_cli, "_user_systemd_private_socket_path", 1248 lambda: type("P", (), {"exists": lambda self: False})(), 1249 ) 1250 monkeypatch.setattr( 1251 gateway_cli, "get_systemd_linger_status", 1252 lambda: (None, "loginctl not found"), 1253 ) 1254 monkeypatch.setattr(gateway_cli.shutil, "which", lambda _: None) 1255 1256 with pytest.raises(gateway_cli.UserSystemdUnavailableError) as exc_info: 1257 gateway_cli._preflight_user_systemd() 1258 1259 assert "sudo loginctl enable-linger" in str(exc_info.value) 1260 1261 def test_linger_enabled_but_socket_still_missing(self, monkeypatch): 1262 """Edge case: linger says yes but the bus socket never came up.""" 1263 monkeypatch.setattr( 1264 gateway_cli, "_user_dbus_socket_path", 1265 lambda: type("P", (), {"exists": lambda self: False})(), 1266 ) 1267 monkeypatch.setattr( 1268 gateway_cli, "_user_systemd_private_socket_path", 1269 lambda: type("P", (), {"exists": lambda self: False})(), 1270 ) 1271 monkeypatch.setattr( 1272 gateway_cli, "get_systemd_linger_status", lambda: (True, ""), 1273 ) 1274 monkeypatch.setattr( 1275 gateway_cli, "_wait_for_user_dbus_socket", lambda timeout=3.0: False, 1276 ) 1277 1278 with pytest.raises(gateway_cli.UserSystemdUnavailableError) as exc_info: 1279 gateway_cli._preflight_user_systemd() 1280 1281 assert "linger is enabled" in str(exc_info.value) 1282 1283 def test_enable_linger_succeeds_and_socket_appears(self, monkeypatch, capsys): 1284 """Happy remediation path: polkit allows enable-linger, socket spawns.""" 1285 monkeypatch.setattr( 1286 gateway_cli, "_user_dbus_socket_path", 1287 lambda: type("P", (), {"exists": lambda self: False})(), 1288 ) 1289 monkeypatch.setattr( 1290 gateway_cli, "_user_systemd_private_socket_path", 1291 lambda: type("P", (), {"exists": lambda self: False})(), 1292 ) 1293 monkeypatch.setattr( 1294 gateway_cli, "get_systemd_linger_status", lambda: (False, ""), 1295 ) 1296 monkeypatch.setattr(gateway_cli.shutil, "which", lambda _: "/usr/bin/loginctl") 1297 1298 class _OkResult: 1299 returncode = 0 1300 stdout = "" 1301 stderr = "" 1302 1303 monkeypatch.setattr( 1304 gateway_cli.subprocess, "run", lambda *a, **kw: _OkResult(), 1305 ) 1306 monkeypatch.setattr( 1307 gateway_cli, "_wait_for_user_dbus_socket", 1308 lambda timeout=5.0: True, 1309 ) 1310 1311 # Should not raise. 1312 gateway_cli._preflight_user_systemd() 1313 out = capsys.readouterr().out 1314 assert "Enabled linger" in out 1315 1316 1317 class TestProfileArg: 1318 """Tests for _profile_arg — returns '--profile <name>' for named profiles.""" 1319 1320 def test_default_hermes_home_returns_empty(self, tmp_path, monkeypatch): 1321 """Default ~/.hermes should not produce a --profile flag.""" 1322 hermes_home = tmp_path / ".hermes" 1323 hermes_home.mkdir() 1324 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1325 monkeypatch.setenv("HERMES_HOME", str(hermes_home)) 1326 result = gateway_cli._profile_arg(str(hermes_home)) 1327 assert result == "" 1328 1329 def test_named_profile_returns_flag(self, tmp_path, monkeypatch): 1330 """~/.hermes/profiles/mybot should return '--profile mybot'.""" 1331 profile_dir = tmp_path / ".hermes" / "profiles" / "mybot" 1332 profile_dir.mkdir(parents=True) 1333 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1334 monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) 1335 result = gateway_cli._profile_arg(str(profile_dir)) 1336 assert result == "--profile mybot" 1337 1338 def test_hash_path_returns_empty(self, tmp_path, monkeypatch): 1339 """Arbitrary non-profile HERMES_HOME should return empty string.""" 1340 custom_home = tmp_path / "custom" / "hermes" 1341 custom_home.mkdir(parents=True) 1342 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1343 monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) 1344 result = gateway_cli._profile_arg(str(custom_home)) 1345 assert result == "" 1346 1347 def test_nested_profile_path_returns_empty(self, tmp_path, monkeypatch): 1348 """~/.hermes/profiles/mybot/subdir should NOT match — too deep.""" 1349 nested = tmp_path / ".hermes" / "profiles" / "mybot" / "subdir" 1350 nested.mkdir(parents=True) 1351 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1352 monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) 1353 result = gateway_cli._profile_arg(str(nested)) 1354 assert result == "" 1355 1356 def test_invalid_profile_name_returns_empty(self, tmp_path, monkeypatch): 1357 """Profile names with invalid chars should not match the regex.""" 1358 bad_profile = tmp_path / ".hermes" / "profiles" / "My Bot!" 1359 bad_profile.mkdir(parents=True) 1360 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1361 monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) 1362 result = gateway_cli._profile_arg(str(bad_profile)) 1363 assert result == "" 1364 1365 def test_systemd_unit_includes_profile(self, tmp_path, monkeypatch): 1366 """generate_systemd_unit should include --profile in ExecStart for named profiles.""" 1367 profile_dir = tmp_path / ".hermes" / "profiles" / "mybot" 1368 profile_dir.mkdir(parents=True) 1369 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1370 monkeypatch.setenv("HERMES_HOME", str(profile_dir)) 1371 monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir) 1372 unit = gateway_cli.generate_systemd_unit(system=False) 1373 assert "--profile mybot" in unit 1374 assert "gateway run --replace" in unit 1375 1376 def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch): 1377 """generate_launchd_plist should include --profile in ProgramArguments for named profiles.""" 1378 profile_dir = tmp_path / ".hermes" / "profiles" / "mybot" 1379 profile_dir.mkdir(parents=True) 1380 monkeypatch.setattr(Path, "home", lambda: tmp_path) 1381 monkeypatch.setenv("HERMES_HOME", str(profile_dir)) 1382 monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir) 1383 plist = gateway_cli.generate_launchd_plist() 1384 assert "<string>--profile</string>" in plist 1385 assert "<string>mybot</string>" in plist 1386 1387 def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch): 1388 profile_dir = tmp_path / ".hermes" / "profiles" / "orcha" 1389 profile_dir.mkdir(parents=True) 1390 machine_home = tmp_path / "machine-home" 1391 machine_home.mkdir() 1392 profile_home = profile_dir / "home" 1393 profile_home.mkdir() 1394 1395 monkeypatch.setattr(Path, "home", lambda: profile_home) 1396 monkeypatch.setenv("HERMES_HOME", str(profile_dir)) 1397 monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir) 1398 monkeypatch.setattr(pwd, "getpwuid", lambda uid: SimpleNamespace(pw_dir=str(machine_home))) 1399 1400 plist_path = gateway_cli.get_launchd_plist_path() 1401 1402 assert plist_path == machine_home / "Library" / "LaunchAgents" / "ai.hermes.gateway-orcha.plist" 1403 1404 1405 class TestRemapPathForUser: 1406 """Unit tests for _remap_path_for_user().""" 1407 1408 def test_remaps_path_under_current_home(self, monkeypatch, tmp_path): 1409 monkeypatch.setattr(Path, "home", lambda: tmp_path / "root") 1410 (tmp_path / "root").mkdir() 1411 result = gateway_cli._remap_path_for_user( 1412 str(tmp_path / "root" / ".hermes" / "hermes-agent"), 1413 str(tmp_path / "alice"), 1414 ) 1415 assert result == str(tmp_path / "alice" / ".hermes" / "hermes-agent") 1416 1417 def test_keeps_system_path_unchanged(self, monkeypatch, tmp_path): 1418 monkeypatch.setattr(Path, "home", lambda: tmp_path / "root") 1419 (tmp_path / "root").mkdir() 1420 result = gateway_cli._remap_path_for_user("/opt/hermes", str(tmp_path / "alice")) 1421 assert result == "/opt/hermes" 1422 1423 def test_noop_when_same_user(self, monkeypatch, tmp_path): 1424 monkeypatch.setattr(Path, "home", lambda: tmp_path / "alice") 1425 (tmp_path / "alice").mkdir() 1426 original = str(tmp_path / "alice" / ".hermes" / "hermes-agent") 1427 result = gateway_cli._remap_path_for_user(original, str(tmp_path / "alice")) 1428 assert result == original 1429 1430 1431 class TestSystemUnitPathRemapping: 1432 """System units must remap ALL paths from the caller's home to the target user.""" 1433 1434 def test_system_unit_has_no_root_paths(self, monkeypatch, tmp_path): 1435 root_home = tmp_path / "root" 1436 root_home.mkdir() 1437 project = root_home / ".hermes" / "hermes-agent" 1438 project.mkdir(parents=True) 1439 venv_bin = project / "venv" / "bin" 1440 venv_bin.mkdir(parents=True) 1441 (venv_bin / "python").write_text("") 1442 1443 target_home = "/home/alice" 1444 1445 monkeypatch.setattr(Path, "home", lambda: root_home) 1446 monkeypatch.setenv("HERMES_HOME", str(root_home / ".hermes")) 1447 monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: root_home / ".hermes") 1448 monkeypatch.setattr(gateway_cli, "PROJECT_ROOT", project) 1449 monkeypatch.setattr(gateway_cli, "_detect_venv_dir", lambda: project / "venv") 1450 monkeypatch.setattr(gateway_cli, "get_python_path", lambda: str(venv_bin / "python")) 1451 monkeypatch.setattr( 1452 gateway_cli, "_system_service_identity", 1453 lambda run_as_user=None: ("alice", "alice", target_home), 1454 ) 1455 1456 unit = gateway_cli.generate_systemd_unit(system=True) 1457 1458 # No root paths should leak into the unit 1459 assert str(root_home) not in unit 1460 # Target user paths should be present 1461 assert "/home/alice" in unit 1462 assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit 1463 1464 1465 class TestDockerAwareGateway: 1466 """Tests for Docker container awareness in gateway commands.""" 1467 1468 def test_run_systemctl_raises_runtimeerror_when_missing(self, monkeypatch): 1469 """_run_systemctl raises RuntimeError with container guidance when systemctl is absent.""" 1470 import pytest 1471 1472 def fake_run(cmd, **kwargs): 1473 raise FileNotFoundError("systemctl") 1474 1475 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 1476 1477 with pytest.raises(RuntimeError, match="systemctl is not available"): 1478 gateway_cli._run_systemctl(["start", "hermes-gateway"]) 1479 1480 def test_run_systemctl_passes_through_on_success(self, monkeypatch): 1481 """_run_systemctl delegates to subprocess.run when systemctl exists.""" 1482 calls = [] 1483 1484 def fake_run(cmd, **kwargs): 1485 calls.append(cmd) 1486 return SimpleNamespace(returncode=0, stdout="", stderr="") 1487 1488 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 1489 1490 result = gateway_cli._run_systemctl(["status", "hermes-gateway"]) 1491 assert result.returncode == 0 1492 assert len(calls) == 1 1493 assert "status" in calls[0] 1494 1495 def test_install_in_container_prints_docker_guidance(self, monkeypatch, capsys): 1496 """'hermes gateway install' inside Docker exits 0 with container guidance.""" 1497 import pytest 1498 1499 monkeypatch.setattr(gateway_cli, "is_managed", lambda: False) 1500 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 1501 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) 1502 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 1503 monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False) 1504 monkeypatch.setattr(gateway_cli, "is_container", lambda: True) 1505 1506 args = SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None) 1507 with pytest.raises(SystemExit) as exc_info: 1508 gateway_cli.gateway_command(args) 1509 1510 assert exc_info.value.code == 0 1511 out = capsys.readouterr().out 1512 assert "Docker" in out or "docker" in out 1513 assert "restart" in out.lower() 1514 1515 def test_uninstall_in_container_prints_docker_guidance(self, monkeypatch, capsys): 1516 """'hermes gateway uninstall' inside Docker exits 0 with container guidance.""" 1517 import pytest 1518 1519 monkeypatch.setattr(gateway_cli, "is_managed", lambda: False) 1520 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 1521 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) 1522 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 1523 monkeypatch.setattr(gateway_cli, "is_container", lambda: True) 1524 1525 args = SimpleNamespace(gateway_command="uninstall", system=False) 1526 with pytest.raises(SystemExit) as exc_info: 1527 gateway_cli.gateway_command(args) 1528 1529 assert exc_info.value.code == 0 1530 out = capsys.readouterr().out 1531 assert "docker" in out.lower() 1532 1533 def test_start_in_container_prints_docker_guidance(self, monkeypatch, capsys): 1534 """'hermes gateway start' inside Docker exits 0 with container guidance.""" 1535 import pytest 1536 1537 monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) 1538 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) 1539 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 1540 monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False) 1541 monkeypatch.setattr(gateway_cli, "is_container", lambda: True) 1542 1543 args = SimpleNamespace(gateway_command="start", system=False) 1544 with pytest.raises(SystemExit) as exc_info: 1545 gateway_cli.gateway_command(args) 1546 1547 assert exc_info.value.code == 0 1548 out = capsys.readouterr().out 1549 assert "docker" in out.lower() 1550 assert "hermes gateway run" in out 1551 1552 1553 class TestLegacyHermesUnitDetection: 1554 """Tests for _find_legacy_hermes_units / has_legacy_hermes_units. 1555 1556 These guard against the scenario that tripped Luis in April 2026: an 1557 older install left a ``hermes.service`` unit behind when the service was 1558 renamed to ``hermes-gateway.service``. After PR #5646 (signal recovery 1559 via systemd), the two services began SIGTERM-flapping over the same 1560 Telegram bot token in a 30-second cycle. 1561 1562 The detector must flag ``hermes.service`` ONLY when it actually runs our 1563 gateway, and must NEVER flag profile units 1564 (``hermes-gateway-<profile>.service``) or unrelated third-party services. 1565 """ 1566 1567 # Minimal ExecStart that looks like our gateway 1568 _OUR_UNIT_TEXT = ( 1569 "[Unit]\nDescription=Hermes Gateway\n[Service]\n" 1570 "ExecStart=/usr/bin/python -m hermes_cli.main gateway run --replace\n" 1571 ) 1572 1573 @staticmethod 1574 def _setup_search_paths(tmp_path, monkeypatch): 1575 """Redirect the legacy search to user_dir + system_dir under tmp_path.""" 1576 user_dir = tmp_path / "user" 1577 system_dir = tmp_path / "system" 1578 user_dir.mkdir() 1579 system_dir.mkdir() 1580 monkeypatch.setattr( 1581 gateway_cli, 1582 "_legacy_unit_search_paths", 1583 lambda: [(False, user_dir), (True, system_dir)], 1584 ) 1585 return user_dir, system_dir 1586 1587 def test_detects_legacy_hermes_service_in_user_scope(self, tmp_path, monkeypatch): 1588 user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch) 1589 legacy = user_dir / "hermes.service" 1590 legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1591 1592 results = gateway_cli._find_legacy_hermes_units() 1593 1594 assert len(results) == 1 1595 name, path, is_system = results[0] 1596 assert name == "hermes.service" 1597 assert path == legacy 1598 assert is_system is False 1599 assert gateway_cli.has_legacy_hermes_units() is True 1600 1601 def test_detects_legacy_hermes_service_in_system_scope(self, tmp_path, monkeypatch): 1602 _, system_dir = self._setup_search_paths(tmp_path, monkeypatch) 1603 legacy = system_dir / "hermes.service" 1604 legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1605 1606 results = gateway_cli._find_legacy_hermes_units() 1607 1608 assert len(results) == 1 1609 name, path, is_system = results[0] 1610 assert name == "hermes.service" 1611 assert path == legacy 1612 assert is_system is True 1613 1614 def test_ignores_profile_unit_hermes_gateway_coder(self, tmp_path, monkeypatch): 1615 """CRITICAL: profile units must NOT be flagged as legacy. 1616 1617 Teknium's concern — ``hermes-gateway-coder.service`` is our standard 1618 naming for the ``coder`` profile. The legacy detector is an explicit 1619 allowlist, not a glob, so profile units are safe. 1620 """ 1621 user_dir, system_dir = self._setup_search_paths(tmp_path, monkeypatch) 1622 # Drop profile units in BOTH scopes with our ExecStart 1623 for base in (user_dir, system_dir): 1624 (base / "hermes-gateway-coder.service").write_text( 1625 self._OUR_UNIT_TEXT, encoding="utf-8" 1626 ) 1627 (base / "hermes-gateway-orcha.service").write_text( 1628 self._OUR_UNIT_TEXT, encoding="utf-8" 1629 ) 1630 (base / "hermes-gateway.service").write_text( 1631 self._OUR_UNIT_TEXT, encoding="utf-8" 1632 ) 1633 1634 results = gateway_cli._find_legacy_hermes_units() 1635 1636 assert results == [] 1637 assert gateway_cli.has_legacy_hermes_units() is False 1638 1639 def test_ignores_unrelated_hermes_service(self, tmp_path, monkeypatch): 1640 """Third-party ``hermes.service`` that isn't ours stays untouched. 1641 1642 If a user has some other package named ``hermes`` installed as a 1643 service, we must not flag it. 1644 """ 1645 user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch) 1646 (user_dir / "hermes.service").write_text( 1647 "[Unit]\nDescription=Some Other Hermes\n[Service]\n" 1648 "ExecStart=/opt/other-hermes/bin/daemon --foreground\n", 1649 encoding="utf-8", 1650 ) 1651 1652 results = gateway_cli._find_legacy_hermes_units() 1653 1654 assert results == [] 1655 assert gateway_cli.has_legacy_hermes_units() is False 1656 1657 def test_returns_empty_when_no_legacy_files_exist(self, tmp_path, monkeypatch): 1658 self._setup_search_paths(tmp_path, monkeypatch) 1659 1660 assert gateway_cli._find_legacy_hermes_units() == [] 1661 assert gateway_cli.has_legacy_hermes_units() is False 1662 1663 def test_detects_both_scopes_simultaneously(self, tmp_path, monkeypatch): 1664 """When a user has BOTH user-scope and system-scope legacy units, 1665 both are reported so the migration step can remove them together.""" 1666 user_dir, system_dir = self._setup_search_paths(tmp_path, monkeypatch) 1667 (user_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1668 (system_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1669 1670 results = gateway_cli._find_legacy_hermes_units() 1671 1672 scopes = sorted(is_system for _, _, is_system in results) 1673 assert scopes == [False, True] 1674 1675 def test_accepts_alternate_execstart_formats(self, tmp_path, monkeypatch): 1676 """Older installs may have used different python invocations. 1677 1678 ExecStart variants we've seen in the wild: 1679 - python -m hermes_cli.main gateway run 1680 - python path/to/hermes_cli/main.py gateway run 1681 - hermes gateway run (direct binary) 1682 - python path/to/gateway/run.py 1683 """ 1684 user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch) 1685 variants = [ 1686 "ExecStart=/venv/bin/python -m hermes_cli.main gateway run --replace", 1687 "ExecStart=/venv/bin/python /opt/hermes/hermes_cli/main.py gateway run", 1688 "ExecStart=/usr/local/bin/hermes gateway run --replace", 1689 "ExecStart=/venv/bin/python /opt/hermes/gateway/run.py", 1690 ] 1691 for i, execstart in enumerate(variants): 1692 name = f"hermes.service" if i == 0 else f"hermes.service" # same name 1693 # Test each variant fresh 1694 (user_dir / "hermes.service").write_text( 1695 f"[Unit]\nDescription=Old Hermes\n[Service]\n{execstart}\n", 1696 encoding="utf-8", 1697 ) 1698 results = gateway_cli._find_legacy_hermes_units() 1699 assert len(results) == 1, f"Variant {i} not detected: {execstart!r}" 1700 1701 def test_print_legacy_unit_warning_is_noop_when_empty(self, tmp_path, monkeypatch, capsys): 1702 self._setup_search_paths(tmp_path, monkeypatch) 1703 1704 gateway_cli.print_legacy_unit_warning() 1705 out = capsys.readouterr().out 1706 1707 assert out == "" 1708 1709 def test_print_legacy_unit_warning_shows_migration_hint(self, tmp_path, monkeypatch, capsys): 1710 user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch) 1711 (user_dir / "hermes.service").write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1712 1713 gateway_cli.print_legacy_unit_warning() 1714 out = capsys.readouterr().out 1715 1716 assert "Legacy" in out 1717 assert "hermes.service" in out 1718 assert "hermes gateway migrate-legacy" in out 1719 1720 def test_handles_unreadable_unit_file_gracefully(self, tmp_path, monkeypatch): 1721 """A permission error reading a unit file must not crash detection.""" 1722 user_dir, _ = self._setup_search_paths(tmp_path, monkeypatch) 1723 unreadable = user_dir / "hermes.service" 1724 unreadable.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1725 # Simulate a read failure — monkeypatch Path.read_text to raise 1726 original_read_text = gateway_cli.Path.read_text 1727 1728 def raising_read_text(self, *args, **kwargs): 1729 if self == unreadable: 1730 raise PermissionError("simulated") 1731 return original_read_text(self, *args, **kwargs) 1732 1733 monkeypatch.setattr(gateway_cli.Path, "read_text", raising_read_text) 1734 1735 # Should not raise 1736 results = gateway_cli._find_legacy_hermes_units() 1737 assert results == [] 1738 1739 1740 class TestRemoveLegacyHermesUnits: 1741 """Tests for remove_legacy_hermes_units (the migration action).""" 1742 1743 _OUR_UNIT_TEXT = ( 1744 "[Unit]\nDescription=Hermes Gateway\n[Service]\n" 1745 "ExecStart=/usr/bin/python -m hermes_cli.main gateway run --replace\n" 1746 ) 1747 1748 @staticmethod 1749 def _setup(tmp_path, monkeypatch, as_root=False): 1750 user_dir = tmp_path / "user" 1751 system_dir = tmp_path / "system" 1752 user_dir.mkdir() 1753 system_dir.mkdir() 1754 monkeypatch.setattr( 1755 gateway_cli, 1756 "_legacy_unit_search_paths", 1757 lambda: [(False, user_dir), (True, system_dir)], 1758 ) 1759 # Mock systemctl — return success for everything 1760 systemctl_calls: list[list[str]] = [] 1761 1762 def fake_run(cmd, **kwargs): 1763 systemctl_calls.append(cmd) 1764 return SimpleNamespace(returncode=0, stdout="", stderr="") 1765 1766 monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) 1767 monkeypatch.setattr(gateway_cli.os, "geteuid", lambda: 0 if as_root else 1000) 1768 return user_dir, system_dir, systemctl_calls 1769 1770 def test_returns_zero_when_no_legacy_units(self, tmp_path, monkeypatch, capsys): 1771 self._setup(tmp_path, monkeypatch) 1772 1773 removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False) 1774 1775 assert removed == 0 1776 assert remaining == [] 1777 assert "No legacy" in capsys.readouterr().out 1778 1779 def test_dry_run_lists_without_removing(self, tmp_path, monkeypatch, capsys): 1780 user_dir, _, calls = self._setup(tmp_path, monkeypatch) 1781 legacy = user_dir / "hermes.service" 1782 legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1783 1784 removed, remaining = gateway_cli.remove_legacy_hermes_units( 1785 interactive=False, dry_run=True 1786 ) 1787 1788 assert removed == 0 1789 assert remaining == [legacy] 1790 assert legacy.exists() # Not removed 1791 assert calls == [] # No systemctl invocations 1792 out = capsys.readouterr().out 1793 assert "dry-run" in out 1794 1795 def test_removes_user_scope_legacy_unit(self, tmp_path, monkeypatch, capsys): 1796 user_dir, _, calls = self._setup(tmp_path, monkeypatch) 1797 legacy = user_dir / "hermes.service" 1798 legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1799 1800 removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False) 1801 1802 assert removed == 1 1803 assert remaining == [] 1804 assert not legacy.exists() 1805 # Must have invoked stop → disable → daemon-reload on user scope 1806 cmds_joined = [" ".join(c) for c in calls] 1807 assert any("--user stop hermes.service" in c for c in cmds_joined) 1808 assert any("--user disable hermes.service" in c for c in cmds_joined) 1809 assert any("--user daemon-reload" in c for c in cmds_joined) 1810 1811 def test_system_scope_without_root_defers_removal(self, tmp_path, monkeypatch, capsys): 1812 _, system_dir, calls = self._setup(tmp_path, monkeypatch, as_root=False) 1813 legacy = system_dir / "hermes.service" 1814 legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1815 1816 removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False) 1817 1818 assert removed == 0 1819 assert remaining == [legacy] 1820 assert legacy.exists() # Not removed — requires sudo 1821 out = capsys.readouterr().out 1822 assert "sudo hermes gateway migrate-legacy" in out 1823 1824 def test_system_scope_with_root_removes(self, tmp_path, monkeypatch, capsys): 1825 _, system_dir, calls = self._setup(tmp_path, monkeypatch, as_root=True) 1826 legacy = system_dir / "hermes.service" 1827 legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1828 1829 removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False) 1830 1831 assert removed == 1 1832 assert remaining == [] 1833 assert not legacy.exists() 1834 cmds_joined = [" ".join(c) for c in calls] 1835 # System-scope uses plain "systemctl" (no --user) 1836 assert any( 1837 c.startswith("systemctl stop hermes.service") for c in cmds_joined 1838 ) 1839 assert any( 1840 c.startswith("systemctl disable hermes.service") for c in cmds_joined 1841 ) 1842 1843 def test_removes_both_scopes_with_root(self, tmp_path, monkeypatch, capsys): 1844 user_dir, system_dir, _ = self._setup(tmp_path, monkeypatch, as_root=True) 1845 user_legacy = user_dir / "hermes.service" 1846 system_legacy = system_dir / "hermes.service" 1847 user_legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1848 system_legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1849 1850 removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False) 1851 1852 assert removed == 2 1853 assert remaining == [] 1854 assert not user_legacy.exists() 1855 assert not system_legacy.exists() 1856 1857 def test_does_not_touch_profile_units_during_migration( 1858 self, tmp_path, monkeypatch, capsys 1859 ): 1860 """Teknium's constraint: profile units (hermes-gateway-coder.service) 1861 must survive a migration call, even if we somehow include them in the 1862 search dir.""" 1863 user_dir, _, _ = self._setup(tmp_path, monkeypatch, as_root=True) 1864 profile_unit = user_dir / "hermes-gateway-coder.service" 1865 profile_unit.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1866 default_unit = user_dir / "hermes-gateway.service" 1867 default_unit.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1868 1869 removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=False) 1870 1871 assert removed == 0 1872 assert remaining == [] 1873 # Both the profile unit and the current default unit must survive 1874 assert profile_unit.exists() 1875 assert default_unit.exists() 1876 1877 def test_interactive_prompt_no_skips_removal(self, tmp_path, monkeypatch, capsys): 1878 """When interactive=True and user answers no, no removal happens.""" 1879 user_dir, _, _ = self._setup(tmp_path, monkeypatch) 1880 legacy = user_dir / "hermes.service" 1881 legacy.write_text(self._OUR_UNIT_TEXT, encoding="utf-8") 1882 1883 monkeypatch.setattr(gateway_cli, "prompt_yes_no", lambda *a, **k: False) 1884 1885 removed, remaining = gateway_cli.remove_legacy_hermes_units(interactive=True) 1886 1887 assert removed == 0 1888 assert remaining == [legacy] 1889 assert legacy.exists() 1890 1891 1892 class TestMigrateLegacyCommand: 1893 """Tests for the `hermes gateway migrate-legacy` subcommand dispatch.""" 1894 1895 def test_migrate_legacy_subparser_accepts_dry_run_and_yes(self): 1896 """Verify the argparse subparser is registered and parses flags.""" 1897 import hermes_cli.main as cli_main 1898 1899 parser = cli_main.build_parser() if hasattr(cli_main, "build_parser") else None 1900 # Fall back to calling main's setup helper if direct access isn't exposed 1901 # The key thing: the subparser must exist. We verify by constructing 1902 # a namespace through argparse directly — but if build_parser isn't 1903 # public, just confirm that `hermes gateway --help` shows it. 1904 import subprocess 1905 import sys 1906 1907 project_root = cli_main.PROJECT_ROOT if hasattr(cli_main, "PROJECT_ROOT") else None 1908 if project_root is None: 1909 import hermes_cli.gateway as gw 1910 project_root = gw.PROJECT_ROOT 1911 1912 result = subprocess.run( 1913 [sys.executable, "-m", "hermes_cli.main", "gateway", "--help"], 1914 cwd=str(project_root), 1915 capture_output=True, 1916 text=True, 1917 timeout=15, 1918 ) 1919 assert result.returncode == 0 1920 assert "migrate-legacy" in result.stdout 1921 1922 def test_gateway_command_migrate_legacy_dispatches( 1923 self, tmp_path, monkeypatch, capsys 1924 ): 1925 """gateway_command(args) with subcmd='migrate-legacy' calls the helper.""" 1926 called = {} 1927 1928 def fake_remove(interactive=True, dry_run=False): 1929 called["interactive"] = interactive 1930 called["dry_run"] = dry_run 1931 return 0, [] 1932 1933 monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove) 1934 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 1935 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 1936 1937 args = SimpleNamespace( 1938 gateway_command="migrate-legacy", dry_run=False, yes=True 1939 ) 1940 gateway_cli.gateway_command(args) 1941 1942 assert called == {"interactive": False, "dry_run": False} 1943 1944 1945 class TestGatewayStatusParser: 1946 def test_gateway_status_subparser_accepts_full_flag(self): 1947 import subprocess 1948 import sys 1949 1950 result = subprocess.run( 1951 [sys.executable, "-m", "hermes_cli.main", "gateway", "status", "-l", "--help"], 1952 cwd=str(gateway_cli.PROJECT_ROOT), 1953 capture_output=True, 1954 text=True, 1955 timeout=15, 1956 ) 1957 1958 assert result.returncode == 0 1959 assert "unrecognized arguments" not in result.stderr 1960 1961 def test_gateway_command_migrate_legacy_dry_run_passes_through( 1962 self, monkeypatch 1963 ): 1964 called = {} 1965 1966 def fake_remove(interactive=True, dry_run=False): 1967 called["interactive"] = interactive 1968 called["dry_run"] = dry_run 1969 return 0, [] 1970 1971 monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove) 1972 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) 1973 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 1974 1975 args = SimpleNamespace( 1976 gateway_command="migrate-legacy", dry_run=True, yes=False 1977 ) 1978 gateway_cli.gateway_command(args) 1979 1980 assert called == {"interactive": True, "dry_run": True} 1981 1982 def test_migrate_legacy_on_unsupported_platform_prints_message( 1983 self, monkeypatch, capsys 1984 ): 1985 monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) 1986 monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) 1987 1988 args = SimpleNamespace( 1989 gateway_command="migrate-legacy", dry_run=False, yes=True 1990 ) 1991 gateway_cli.gateway_command(args) 1992 1993 out = capsys.readouterr().out 1994 assert "only applies to systemd" in out 1995 1996 1997 class TestSystemdInstallOffersLegacyRemoval: 1998 """Verify that systemd_install prompts to remove legacy units first.""" 1999 2000 def test_install_offers_removal_when_legacy_detected( 2001 self, tmp_path, monkeypatch, capsys 2002 ): 2003 """When legacy units exist, install flow should call the removal 2004 helper before writing the new unit.""" 2005 remove_called = {} 2006 2007 def fake_remove(interactive=True, dry_run=False): 2008 remove_called["invoked"] = True 2009 remove_called["interactive"] = interactive 2010 return 1, [] 2011 2012 # has_legacy_hermes_units must return True 2013 monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: True) 2014 monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove) 2015 monkeypatch.setattr(gateway_cli, "print_legacy_unit_warning", lambda: None) 2016 # Answer "yes" to the legacy-removal prompt 2017 monkeypatch.setattr(gateway_cli, "prompt_yes_no", lambda *a, **k: True) 2018 2019 # Mock the rest of the install flow 2020 unit_path = tmp_path / "hermes-gateway.service" 2021 monkeypatch.setattr( 2022 gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path 2023 ) 2024 monkeypatch.setattr( 2025 gateway_cli, 2026 "generate_systemd_unit", 2027 lambda system=False, run_as_user=None: "unit text\n", 2028 ) 2029 monkeypatch.setattr( 2030 gateway_cli.subprocess, 2031 "run", 2032 lambda cmd, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""), 2033 ) 2034 monkeypatch.setattr(gateway_cli, "_ensure_linger_enabled", lambda: None) 2035 2036 gateway_cli.systemd_install() 2037 2038 assert remove_called.get("invoked") is True 2039 assert remove_called.get("interactive") is False # prompted elsewhere 2040 2041 def test_install_declines_legacy_removal_when_user_says_no( 2042 self, tmp_path, monkeypatch 2043 ): 2044 """When legacy units exist and user declines, install still proceeds 2045 but doesn't touch them.""" 2046 remove_called = {"invoked": False} 2047 2048 def fake_remove(interactive=True, dry_run=False): 2049 remove_called["invoked"] = True 2050 return 0, [] 2051 2052 monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: True) 2053 monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove) 2054 monkeypatch.setattr(gateway_cli, "print_legacy_unit_warning", lambda: None) 2055 monkeypatch.setattr(gateway_cli, "prompt_yes_no", lambda *a, **k: False) 2056 2057 unit_path = tmp_path / "hermes-gateway.service" 2058 monkeypatch.setattr( 2059 gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path 2060 ) 2061 monkeypatch.setattr( 2062 gateway_cli, 2063 "generate_systemd_unit", 2064 lambda system=False, run_as_user=None: "unit text\n", 2065 ) 2066 monkeypatch.setattr( 2067 gateway_cli.subprocess, 2068 "run", 2069 lambda cmd, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""), 2070 ) 2071 monkeypatch.setattr(gateway_cli, "_ensure_linger_enabled", lambda: None) 2072 2073 gateway_cli.systemd_install() 2074 2075 # Helper must NOT have been called 2076 assert remove_called["invoked"] is False 2077 # New unit should still have been written 2078 assert unit_path.exists() 2079 assert unit_path.read_text() == "unit text\n" 2080 2081 def test_install_skips_legacy_check_when_none_present( 2082 self, tmp_path, monkeypatch 2083 ): 2084 """No legacy → no prompt, no helper call.""" 2085 prompt_called = {"count": 0} 2086 2087 def counting_prompt(*a, **k): 2088 prompt_called["count"] += 1 2089 return True 2090 2091 remove_called = {"invoked": False} 2092 2093 def fake_remove(interactive=True, dry_run=False): 2094 remove_called["invoked"] = True 2095 return 0, [] 2096 2097 monkeypatch.setattr(gateway_cli, "has_legacy_hermes_units", lambda: False) 2098 monkeypatch.setattr(gateway_cli, "remove_legacy_hermes_units", fake_remove) 2099 monkeypatch.setattr(gateway_cli, "prompt_yes_no", counting_prompt) 2100 2101 unit_path = tmp_path / "hermes-gateway.service" 2102 monkeypatch.setattr( 2103 gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path 2104 ) 2105 monkeypatch.setattr( 2106 gateway_cli, 2107 "generate_systemd_unit", 2108 lambda system=False, run_as_user=None: "unit text\n", 2109 ) 2110 monkeypatch.setattr( 2111 gateway_cli.subprocess, 2112 "run", 2113 lambda cmd, **kw: SimpleNamespace(returncode=0, stdout="", stderr=""), 2114 ) 2115 monkeypatch.setattr(gateway_cli, "_ensure_linger_enabled", lambda: None) 2116 2117 gateway_cli.systemd_install() 2118 2119 assert prompt_called["count"] == 0 2120 assert remove_called["invoked"] is False