mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Sessions now survive `hermes gateway stop` / `restart` on native Windows. Previously the gateway died on schtasks `/End` + os.kill SIGTERM without ever running the drain loop, so the v0.13.0 session-resume feature (#21192) silently broke on Windows: `resume_pending=True` was never written, and the next boot started with a blank conversation history (issue #33778). Root cause is twofold and the reporter only identified half of it: 1. `hermes_cli/gateway_windows.py::stop()` did not write the `planned_stop_marker` before signalling. The reporter caught this. 2. The bigger reason: `asyncio.add_signal_handler` raises NotImplementedError for SIGTERM/SIGINT on Windows, so even if the marker had been written, the gateway's existing SIGTERM handler (which is what calls `runner.stop()` and the `mark_resume_pending` loop) was never invoked. Writing the marker would have been necessary-but-insufficient. The fix has two parts: * gateway/run.py: new `_run_planned_stop_watcher` daemon thread polls for the planned-stop marker file every 0.5s. When the marker appears it `loop.call_soon_threadsafe(shutdown_signal_handler, None)` — the same shutdown path a real SIGTERM would have driven, including the pre-drain `mark_resume_pending` writes (run.py:5977) and graceful drain wait. The existing signal handler already accepts `received_signal=None` and falls through to `consume_planned_stop_marker_for_self()`, so no handler changes needed. Runs on every platform as cheap belt-and-suspenders. * hermes_cli/gateway_windows.py: `stop()` now writes the marker for the running gateway PID and waits up to `agent.restart_drain_timeout` (default 30s) for the PID to exit cleanly. On clean drain, the kill sweep is non-forceful; on timeout, escalates to `kill_gateway_processes(force=True)` which routes to taskkill /T /F per `references/windows-native-support.md`. Validation: * 7 new tests in tests/gateway/test_planned_stop_watcher.py covering: marker→handler dispatch, no-marker idle, already-draining skip, not-yet-running skip, stop_event responsiveness, fire-once semantics, error tolerance. * 8 new tests in tests/hermes_cli/test_gateway_windows.py covering: marker-before-kill ordering, clean-drain skips force-kill, drain-timeout escalates to force=True, no-pid-skips-drain, invalid-pid handling, fast-exit success, timeout failure, marker-write-failure tolerance. * E2E (Linux, detached orphan): write_planned_stop_marker(pid) + `_drain_gateway_pid(pid, 5.0)` returns True in 0.5s after the victim sees the marker and exits. Tested with a double-forked subprocess so the test parent isn't holding it as a zombie. * Targeted: tests/gateway/{restart_drain,restart_resume_pending, signal,signal_format,status,shutdown_forensics,approve_deny_commands, planned_stop_watcher} + tests/hermes_cli/{gateway_windows, gateway_service} → 519/519. What was wrong with the reporter's claim (for future archaeology): they described the symptom as "no `resume_pending=True` written to `sessions.json`" — but Hermes uses `state.db` (SQLite), not `sessions.json`, and `mark_resume_pending` is called regardless of the marker (the marker only affects exit code 0 vs 1 for systemd revival semantics). The real session-loss path is the missing drain on Windows, not a missing marker. Both halves are fixed here. Closes #33778.
701 lines
No EOL
32 KiB
Python
701 lines
No EOL
32 KiB
Python
"""Tests for hermes_cli.gateway_windows."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.gateway as gateway
|
|
import hermes_cli.gateway_windows as gateway_windows
|
|
import hermes_cli.setup as setup
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"detail",
|
|
[
|
|
"ERROR: Access is denied.",
|
|
"ERROR: Acceso denegado.",
|
|
"ERROR: Přístup byl odepřen.",
|
|
"schtasks timed out after 15s",
|
|
"schtasks produced no output",
|
|
],
|
|
)
|
|
def test_schtasks_fallback_patterns_cover_localized_access_denied(detail):
|
|
"""Localized schtasks access-denied errors should use Startup fallback."""
|
|
|
|
assert gateway_windows._should_fall_back(1, detail) is True
|
|
|
|
|
|
def test_schtasks_fallback_does_not_hide_unknown_errors():
|
|
assert gateway_windows._should_fall_back(1, "ERROR: The system cannot find the file specified.") is False
|
|
|
|
|
|
def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch, tmp_path):
|
|
"""Avoid uv's venv pythonw launcher because it respawns console python.exe."""
|
|
|
|
project = tmp_path / "project"
|
|
scripts = project / "venv" / "Scripts"
|
|
site_packages = project / "venv" / "Lib" / "site-packages"
|
|
base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
|
|
scripts.mkdir(parents=True)
|
|
site_packages.mkdir(parents=True)
|
|
base.mkdir(parents=True)
|
|
|
|
venv_python = scripts / "python.exe"
|
|
venv_pythonw = scripts / "pythonw.exe"
|
|
base_pythonw = base / "pythonw.exe"
|
|
for exe in (venv_python, venv_pythonw, base_pythonw):
|
|
exe.write_text("", encoding="utf-8")
|
|
(project / "venv" / "pyvenv.cfg").write_text(
|
|
f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
import hermes_cli.gateway as gateway
|
|
|
|
monkeypatch.setattr(gateway_windows.sys, "platform", "win32")
|
|
monkeypatch.setattr(gateway, "PROJECT_ROOT", project)
|
|
monkeypatch.setattr(gateway, "get_python_path", lambda: str(venv_python))
|
|
monkeypatch.setattr(gateway, "_profile_arg", lambda hermes_home: "")
|
|
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: str(tmp_path / "hermes-home"))
|
|
|
|
argv, cwd, env_overlay = gateway_windows._build_gateway_argv()
|
|
|
|
assert argv[:3] == [str(base_pythonw), "-m", "hermes_cli.main"]
|
|
assert cwd == str(project)
|
|
assert env_overlay["VIRTUAL_ENV"] == str(project / "venv")
|
|
assert str(project) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)
|
|
assert str(site_packages) in env_overlay["PYTHONPATH"].split(gateway_windows.os.pathsep)
|
|
|
|
|
|
def _arrange_startup_fallback(monkeypatch, tmp_path, running_pids):
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
startup_entry = tmp_path / "Startup" / "Hermes_Gateway_alice.cmd"
|
|
calls = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (False, True))
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_install_scheduled_task",
|
|
lambda task_name, script_path: (
|
|
False,
|
|
"schtasks /Create failed (code 1): ERROR: Access is denied.",
|
|
),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_should_fall_back", lambda code, detail: True)
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: True)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_launch_elevated_install",
|
|
lambda force=False, start_now=None, start_on_login=None: calls.append(("elevate", force, start_now, start_on_login)) or True,
|
|
)
|
|
|
|
def fake_install_startup_entry(path: Path) -> Path:
|
|
calls.append(("install_startup", path))
|
|
return startup_entry
|
|
|
|
monkeypatch.setattr(gateway_windows, "_install_startup_entry", fake_install_startup_entry)
|
|
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda path: calls.append(("spawn", path)) or 12345)
|
|
monkeypatch.setattr(gateway_windows, "_report_gateway_start", lambda via: calls.append(("report_start", via)))
|
|
monkeypatch.setattr(gateway_windows, "_print_next_steps", lambda: calls.append(("next_steps", None)))
|
|
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: running_pids)
|
|
monkeypatch.setattr(gateway, "_profile_arg", lambda: "--profile alice")
|
|
return script_path, calls
|
|
|
|
|
|
def test_gateway_cmd_script_uses_pythonw_without_replace_or_start_churn(monkeypatch):
|
|
"""Scheduled Task wrapper should launch pythonw once and avoid replace loops."""
|
|
monkeypatch.setattr(gateway_windows, "_derive_venv_pythonw", lambda exe: exe.replace("python.exe", "pythonw.exe"))
|
|
|
|
content = gateway_windows._build_gateway_cmd_script(
|
|
r"C:\\Hermes\\hermes-agent\\venv\\Scripts\\python.exe",
|
|
r"C:\\Hermes\\hermes-agent",
|
|
r"C:\\HermesHome\\profiles\\alice",
|
|
"--profile alice",
|
|
)
|
|
|
|
assert "pythonw.exe" in content
|
|
assert "gateway run" in content
|
|
assert "--replace" not in content
|
|
assert "start \"\"" not in content
|
|
assert "exit /b 0" in content
|
|
|
|
|
|
def test_elevated_gateway_command_uses_pythonw_hidden_console(monkeypatch):
|
|
"""UAC handoff should not leave a second elevated cmd.exe window open."""
|
|
calls = []
|
|
|
|
class FakeShell32:
|
|
def ShellExecuteW(self, hwnd, verb, executable, params, cwd, show):
|
|
calls.append((hwnd, verb, executable, params, cwd, show))
|
|
return 33
|
|
|
|
class FakeWindll:
|
|
shell32 = FakeShell32()
|
|
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "_current_profile_cli_args", lambda: ["--profile", "alice"])
|
|
monkeypatch.setattr(gateway_windows, "_derive_venv_pythonw", lambda exe: exe.replace("python.exe", "pythonw.exe"))
|
|
monkeypatch.setattr(gateway_windows.sys, "executable", r"C:\Hermes\venv\Scripts\python.exe")
|
|
monkeypatch.setattr(gateway_windows.ctypes, "windll", FakeWindll(), raising=False)
|
|
|
|
assert gateway_windows._launch_elevated_gateway_command("install", ["--start-now", "--elevated-handoff"])
|
|
|
|
assert len(calls) == 1
|
|
_hwnd, verb, executable, params, cwd, show = calls[0]
|
|
assert verb == "runas"
|
|
assert executable.endswith("pythonw.exe")
|
|
assert "--profile alice gateway install --start-now --elevated-handoff" in params
|
|
assert show == 0
|
|
assert cwd
|
|
|
|
|
|
def test_install_scheduled_task_recreates_instead_of_change(monkeypatch, tmp_path):
|
|
"""Install must delete+create so stale minute-repeat task settings are not preserved."""
|
|
calls = []
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
|
|
def fake_schtasks(args):
|
|
calls.append(tuple(args))
|
|
if args[0] == "/Delete":
|
|
return (0, "SUCCESS", "")
|
|
if args[0] == "/Create":
|
|
return (0, "SUCCESS", "")
|
|
raise AssertionError(f"unexpected schtasks args: {args}")
|
|
|
|
monkeypatch.setattr(gateway_windows, "_exec_schtasks", fake_schtasks)
|
|
ok, detail = gateway_windows._install_scheduled_task("Hermes_Gateway_alice", script_path)
|
|
|
|
assert ok is True
|
|
assert "/Change" not in [arg for call in calls for arg in call]
|
|
assert calls[0][:4] == ("/Delete", "/F", "/TN", "Hermes_Gateway_alice")
|
|
assert calls[1][0] == "/Create"
|
|
assert "/SC" in calls[1]
|
|
assert "ONLOGON" in calls[1]
|
|
|
|
|
|
def test_install_scheduled_task_success_start_now_uses_direct_spawn_not_task_run(monkeypatch, tmp_path, capsys):
|
|
"""Install start-now should not /Run the task; that preserved old restart loops."""
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
calls = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (True, True))
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: True)
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_install_scheduled_task",
|
|
lambda task_name, script_path: (True, "Created Scheduled Task 'Hermes_Gateway_alice'"),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda: [])
|
|
monkeypatch.setattr(gateway_windows, "_exec_schtasks", lambda args: calls.append(("schtasks", tuple(args))) or (0, "", ""))
|
|
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda path=None: calls.append(("spawn", path)) or 12345)
|
|
monkeypatch.setattr(gateway_windows, "_report_gateway_start", lambda via: calls.append(("report_start", via)))
|
|
monkeypatch.setattr(gateway_windows, "_print_next_steps", lambda: calls.append(("next_steps", None)))
|
|
|
|
gateway_windows.install(force=False)
|
|
|
|
assert not any(call[0] == "schtasks" and "/Run" in call[1] for call in calls)
|
|
assert ("spawn", None) in calls
|
|
assert any(call[0] == "report_start" for call in calls)
|
|
out = capsys.readouterr().out
|
|
assert "auto-start installed for Windows login" in out
|
|
|
|
|
|
def test_install_scheduled_task_success_does_not_auto_start(monkeypatch, tmp_path, capsys):
|
|
"""Install should register/update the task only; start is explicit."""
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
calls = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (False, True))
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: True)
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_install_scheduled_task",
|
|
lambda task_name, script_path: (True, "Created Scheduled Task 'Hermes_Gateway_alice'"),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_exec_schtasks", lambda args: calls.append(("schtasks", tuple(args))) or (0, "", ""))
|
|
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda path=None: calls.append(("spawn", path)) or 12345)
|
|
monkeypatch.setattr(gateway_windows, "_report_gateway_start", lambda via: calls.append(("report_start", via)))
|
|
monkeypatch.setattr(gateway_windows, "_print_next_steps", lambda: calls.append(("next_steps", None)))
|
|
|
|
gateway_windows.install(force=False)
|
|
|
|
assert not any(call[0] == "schtasks" and "/Run" in call[1] for call in calls)
|
|
assert not any(call[0] == "spawn" for call in calls)
|
|
assert not any(call[0] == "report_start" for call in calls)
|
|
assert ("next_steps", None) in calls
|
|
out = capsys.readouterr().out
|
|
assert "auto-start installed for Windows login" in out
|
|
|
|
|
|
def test_install_access_denied_launches_elevated_install_before_startup_fallback(monkeypatch, tmp_path, capsys):
|
|
"""Non-admin Scheduled Task access denied should hand off to UAC elevation."""
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
calls = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (False, True))
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_install_scheduled_task",
|
|
lambda task_name, script_path: (
|
|
False,
|
|
"schtasks /Create failed (code 1): ERROR: Access is denied.",
|
|
),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: False)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_launch_elevated_install",
|
|
lambda force=False, start_now=None, start_on_login=None: calls.append(("elevate", force, start_now, start_on_login)) or True,
|
|
)
|
|
monkeypatch.setattr(setup, "prompt_yes_no", lambda prompt, default=True: calls.append(("prompt", prompt, default)) or True)
|
|
monkeypatch.setattr(gateway_windows, "_install_startup_entry", lambda path: calls.append(("install_startup", path)) or path)
|
|
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda path=None: calls.append(("spawn", path)) or 12345)
|
|
|
|
gateway_windows.install(force=True)
|
|
|
|
assert calls == [("prompt", " Open the UAC prompt now?", False), ("elevate", True, False, True)]
|
|
out = capsys.readouterr().out
|
|
assert "administrator approval" in out
|
|
assert "UAC is Windows' admin approval prompt" in out
|
|
assert "Launched elevated Hermes gateway install prompt" in out
|
|
|
|
|
|
def test_install_prompts_start_choices_before_uac(monkeypatch, tmp_path, capsys):
|
|
"""Windows install asks start-now and auto-start before any UAC handoff."""
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
calls = []
|
|
answers = iter([True, True, True])
|
|
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_install_scheduled_task",
|
|
lambda task_name, script_path: (
|
|
False,
|
|
"schtasks /Create failed (code 1): ERROR: Access is denied.",
|
|
),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: False)
|
|
monkeypatch.setattr(setup, "prompt_yes_no", lambda prompt, default=True: calls.append(("prompt", prompt, default)) or next(answers))
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_launch_elevated_install",
|
|
lambda force=False, start_now=None, start_on_login=None: calls.append(("elevate", force, start_now, start_on_login)) or True,
|
|
)
|
|
|
|
gateway_windows.install(force=False)
|
|
|
|
assert calls == [
|
|
("prompt", "Start the gateway now after install?", True),
|
|
("prompt", "Start the gateway automatically on Windows login with a Scheduled Task?", True),
|
|
("prompt", " Open the UAC prompt now?", False),
|
|
("elevate", False, True, True),
|
|
]
|
|
out = capsys.readouterr().out
|
|
assert "elevated install will start the gateway afterwards" in out
|
|
|
|
|
|
def test_install_start_now_without_login_autostart_never_escalates(monkeypatch, capsys):
|
|
"""If auto-start is declined, install can start directly without touching schtasks/UAC."""
|
|
calls = []
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (True, False))
|
|
monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda: [])
|
|
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda path=None: calls.append(("spawn", path)) or 12345)
|
|
monkeypatch.setattr(gateway_windows, "_report_gateway_start", lambda via: calls.append(("report_start", via)))
|
|
monkeypatch.setattr(gateway_windows, "_install_scheduled_task", lambda *args, **kwargs: calls.append(("install_task", args)) or (True, "should not happen"))
|
|
monkeypatch.setattr(gateway_windows, "_launch_elevated_install", lambda *args, **kwargs: calls.append(("elevate", args, kwargs)) or True)
|
|
|
|
gateway_windows.install(force=False)
|
|
|
|
assert not any(call[0] in {"install_task", "elevate"} for call in calls)
|
|
assert ("spawn", None) in calls
|
|
assert any(call[0] == "report_start" for call in calls)
|
|
out = capsys.readouterr().out
|
|
assert "Skipped Windows login auto-start install" in out
|
|
|
|
|
|
def test_start_noops_when_gateway_already_running(monkeypatch, capsys):
|
|
"""Repeated start should not invoke schtasks /Run or spawn another process."""
|
|
calls = []
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (False, True))
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "_gateway_pids", lambda: [27128])
|
|
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: calls.append("task_check") or True)
|
|
monkeypatch.setattr(gateway_windows, "_exec_schtasks", lambda args: calls.append(("schtasks", tuple(args))) or (0, "", ""))
|
|
monkeypatch.setattr(gateway_windows, "_spawn_detached", lambda path=None: calls.append(("spawn", path)) or 12345)
|
|
|
|
gateway_windows.start()
|
|
|
|
assert calls == []
|
|
out = capsys.readouterr().out
|
|
assert "already running" in out
|
|
assert "27128" in out
|
|
|
|
|
|
def test_install_startup_fallback_does_not_spawn_when_gateway_already_running(monkeypatch, tmp_path, capsys):
|
|
"""Repeated Windows fallback installs should not spawn duplicate gateways."""
|
|
script_path, calls = _arrange_startup_fallback(monkeypatch, tmp_path, [24476])
|
|
|
|
gateway_windows.install(force=False)
|
|
|
|
assert ("install_startup", script_path) in calls
|
|
assert not any(call[0] == "spawn" for call in calls)
|
|
assert not any(call[0] == "report_start" for call in calls)
|
|
assert ("next_steps", None) in calls
|
|
out = capsys.readouterr().out
|
|
assert "already running" in out
|
|
assert "24476" in out
|
|
|
|
|
|
def test_install_startup_fallback_does_not_auto_spawn_when_gateway_stopped(monkeypatch, tmp_path, capsys):
|
|
"""Startup fallback install should only install login item, not launch pythonw."""
|
|
script_path, calls = _arrange_startup_fallback(monkeypatch, tmp_path, [])
|
|
|
|
gateway_windows.install(force=False)
|
|
|
|
assert ("install_startup", script_path) in calls
|
|
assert not any(call[0] == "spawn" for call in calls)
|
|
assert not any(call[0] == "report_start" for call in calls)
|
|
assert ("next_steps", None) in calls
|
|
out = capsys.readouterr().out
|
|
assert "gateway not started now" in out
|
|
assert "hermes --profile alice gateway start" in out
|
|
|
|
|
|
def test_install_access_denied_declined_elevation_uses_startup_fallback(monkeypatch, tmp_path, capsys):
|
|
"""Install should ask before UAC; declining keeps the non-jarring fallback path."""
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
calls = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (False, True))
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "_write_task_script", lambda: script_path)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_install_scheduled_task",
|
|
lambda task_name, script_path: (
|
|
False,
|
|
"schtasks /Create failed (code 1): ERROR: Access is denied.",
|
|
),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: False)
|
|
monkeypatch.setattr(setup, "prompt_yes_no", lambda prompt, default=True: calls.append(("prompt", prompt, default)) or False)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_launch_elevated_install",
|
|
lambda force=False, start_now=None, start_on_login=None: calls.append(("elevate", force, start_now, start_on_login)) or True,
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_install_startup_entry", lambda path: calls.append(("install_startup", path)) or path)
|
|
monkeypatch.setattr(gateway, "find_gateway_pids", lambda: [])
|
|
monkeypatch.setattr(gateway, "_profile_arg", lambda: "--profile alice")
|
|
monkeypatch.setattr(gateway_windows, "_print_next_steps", lambda: calls.append(("next_steps", None)))
|
|
|
|
gateway_windows.install(force=False)
|
|
|
|
assert ("prompt", " Open the UAC prompt now?", False) in calls
|
|
assert not any(call[0] == "elevate" for call in calls)
|
|
assert ("install_startup", script_path) in calls
|
|
out = capsys.readouterr().out
|
|
assert "Skipped elevation" in out
|
|
assert "UAC is Windows' admin approval prompt" in out
|
|
|
|
|
|
def test_uninstall_access_denied_prompts_before_elevating(monkeypatch, tmp_path, capsys):
|
|
"""Uninstall should hand off to an elevated uninstall only after user consent."""
|
|
calls = []
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
startup_entry = tmp_path / "Startup" / "Hermes_Gateway_alice.cmd"
|
|
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (False, True))
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script_path)
|
|
monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: startup_entry)
|
|
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: True)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_exec_schtasks",
|
|
lambda args: calls.append(("schtasks", tuple(args))) or (1, "", "ERROR: Access is denied."),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: False)
|
|
monkeypatch.setattr(setup, "prompt_yes_no", lambda prompt, default=True: calls.append(("prompt", prompt, default)) or True)
|
|
monkeypatch.setattr(gateway_windows, "_launch_elevated_uninstall", lambda: calls.append(("elevate_uninstall", None)) or True)
|
|
|
|
gateway_windows.uninstall()
|
|
|
|
assert ("prompt", " Open the UAC prompt now?", False) in calls
|
|
assert ("elevate_uninstall", None) in calls
|
|
out = capsys.readouterr().out
|
|
assert "uninstall needs administrator approval" in out
|
|
assert "UAC is Windows' admin approval prompt" in out
|
|
assert "Launched elevated Hermes gateway uninstall prompt" in out
|
|
|
|
|
|
def test_uninstall_access_denied_declined_keeps_task_and_cleans_files(monkeypatch, tmp_path, capsys):
|
|
"""Declining UAC should not surprise the user, but should still remove user-writable artifacts."""
|
|
calls = []
|
|
script_path = tmp_path / "Hermes_Gateway_alice.cmd"
|
|
startup_entry = tmp_path / "Startup" / "Hermes_Gateway_alice.cmd"
|
|
startup_entry.parent.mkdir(parents=True)
|
|
script_path.write_text("task", encoding="utf-8")
|
|
startup_entry.write_text("startup", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(gateway_windows, "_prompt_install_choices", lambda *args, **kwargs: (False, True))
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "get_task_name", lambda: "Hermes_Gateway_alice")
|
|
monkeypatch.setattr(gateway_windows, "get_task_script_path", lambda: script_path)
|
|
monkeypatch.setattr(gateway_windows, "get_startup_entry_path", lambda: startup_entry)
|
|
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: True)
|
|
monkeypatch.setattr(
|
|
gateway_windows,
|
|
"_exec_schtasks",
|
|
lambda args: calls.append(("schtasks", tuple(args))) or (1, "", "ERROR: Access is denied."),
|
|
)
|
|
monkeypatch.setattr(gateway_windows, "_is_running_as_admin", lambda: False)
|
|
monkeypatch.setattr(setup, "prompt_yes_no", lambda prompt, default=True: calls.append(("prompt", prompt, default)) or False)
|
|
monkeypatch.setattr(gateway_windows, "_launch_elevated_uninstall", lambda: calls.append(("elevate_uninstall", None)) or True)
|
|
|
|
gateway_windows.uninstall()
|
|
|
|
assert not any(call[0] == "elevate_uninstall" for call in calls)
|
|
assert not script_path.exists()
|
|
assert not startup_entry.exists()
|
|
out = capsys.readouterr().out
|
|
assert "Skipped elevation" in out
|
|
assert "UAC is Windows' admin approval prompt" in out
|
|
assert "Scheduled Task still registered" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stop() drain semantics — issue #33778
|
|
#
|
|
# Background: on Windows, asyncio.add_signal_handler raises NotImplementedError,
|
|
# so the gateway's SIGTERM handler (which drains in-flight agents and writes
|
|
# resume_pending=True) never fires when `hermes gateway stop` kills the
|
|
# process. The fix: stop() writes the planned_stop_marker first, waits for
|
|
# the gateway's marker-watcher thread to drain + exit cleanly, then escalates
|
|
# to taskkill if drain times out.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_stop_writes_planned_stop_marker_before_killing(monkeypatch):
|
|
"""stop() must write the planned-stop marker BEFORE any kill signal.
|
|
|
|
Without this, the gateway's drain loop never runs on Windows and
|
|
sessions silently lose context across restarts.
|
|
"""
|
|
pid = 99999
|
|
events = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
|
|
|
# Stub the marker write so we can record the order of operations.
|
|
from gateway import status as status_mod
|
|
|
|
def fake_write_marker(target_pid):
|
|
events.append(("write_marker", target_pid))
|
|
return True
|
|
|
|
def fake_pid_exists(check_pid):
|
|
# Drain succeeds: pid "exits" right after the marker write.
|
|
return ("write_marker", pid) not in events
|
|
|
|
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write_marker)
|
|
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
|
|
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
|
|
|
|
def fake_kill(**kwargs):
|
|
events.append(("kill", kwargs.get("force", False)))
|
|
return 0
|
|
|
|
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
|
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
|
|
|
|
gateway_windows.stop()
|
|
|
|
# Marker MUST be written before any kill.
|
|
kinds = [e[0] for e in events]
|
|
assert "write_marker" in kinds, "stop() never wrote the planned-stop marker"
|
|
marker_idx = kinds.index("write_marker")
|
|
kill_idx = kinds.index("kill") if "kill" in kinds else len(kinds)
|
|
assert marker_idx < kill_idx, (
|
|
f"stop() killed before writing the marker (events={events})"
|
|
)
|
|
|
|
|
|
def test_stop_waits_for_graceful_drain_before_force_kill(monkeypatch):
|
|
"""When drain succeeds, stop() should NOT force-kill the gateway.
|
|
|
|
drained=True means the gateway exited cleanly after seeing the
|
|
marker — escalating to taskkill /F afterwards would be wasted
|
|
work and may emit confusing "killed N processes" output.
|
|
"""
|
|
pid = 88888
|
|
events = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
|
|
|
from gateway import status as status_mod
|
|
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
|
|
|
# Simulate the gateway exiting cleanly after one poll tick.
|
|
poll_count = [0]
|
|
def fake_pid_exists(check_pid):
|
|
poll_count[0] += 1
|
|
return poll_count[0] < 2 # alive on first poll, gone on second
|
|
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
|
|
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
|
|
|
|
def fake_kill(**kwargs):
|
|
events.append(("kill", kwargs.get("force", False)))
|
|
return 0
|
|
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
|
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
|
|
|
|
gateway_windows.stop()
|
|
|
|
# kill_gateway_processes is still called as the no-op sweep, but
|
|
# NOT with force=True — drain succeeded, gateway is already gone.
|
|
assert events == [("kill", False)], (
|
|
f"After clean drain, force kill should be disabled (events={events})"
|
|
)
|
|
|
|
|
|
def test_stop_escalates_to_force_kill_when_drain_times_out(monkeypatch):
|
|
"""When drain times out, stop() MUST escalate to force=True.
|
|
|
|
Drain timeout = gateway is stuck or unresponsive. Without the
|
|
taskkill /T /F escalation, the gateway stays alive and the next
|
|
`hermes gateway start` fails with "another instance is running".
|
|
"""
|
|
pid = 77777
|
|
events = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
|
|
|
from gateway import status as status_mod
|
|
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
|
# PID never exits — drain times out.
|
|
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: True)
|
|
monkeypatch.setattr(status_mod, "get_running_pid", lambda: pid)
|
|
|
|
def fake_kill(**kwargs):
|
|
events.append(("kill", kwargs.get("force", False)))
|
|
return 1
|
|
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
|
# Tiny drain timeout to keep the test fast.
|
|
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 1.0)
|
|
|
|
gateway_windows.stop()
|
|
|
|
# When drain times out, kill is invoked with force=True so taskkill /T /F
|
|
# walks the process tree.
|
|
assert events == [("kill", True)], (
|
|
f"After drain timeout, kill must use force=True (events={events})"
|
|
)
|
|
|
|
|
|
def test_stop_no_running_gateway_skips_drain(monkeypatch):
|
|
"""When no gateway is running, skip the drain wait entirely."""
|
|
events = []
|
|
|
|
monkeypatch.setattr(gateway_windows, "_assert_windows", lambda: None)
|
|
monkeypatch.setattr(gateway_windows, "is_task_registered", lambda: False)
|
|
|
|
from gateway import status as status_mod
|
|
monkeypatch.setattr(status_mod, "get_running_pid", lambda: None)
|
|
|
|
def fake_write_marker(target_pid):
|
|
events.append(("write_marker", target_pid))
|
|
return True
|
|
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write_marker)
|
|
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: False)
|
|
|
|
def fake_kill(**kwargs):
|
|
events.append(("kill", kwargs.get("force", False)))
|
|
return 0
|
|
monkeypatch.setattr("hermes_cli.gateway.kill_gateway_processes", fake_kill)
|
|
monkeypatch.setattr("hermes_cli.gateway._get_restart_drain_timeout", lambda: 5.0)
|
|
|
|
gateway_windows.stop()
|
|
|
|
# With no PID to drain, no marker is written. Kill sweep still runs
|
|
# (defensive — covers the case where a stray gateway is alive without
|
|
# a PID file). force=True because drained=False.
|
|
assert ("write_marker", None) not in events
|
|
assert all(e[0] != "write_marker" for e in events), (
|
|
f"Should not write marker when no PID is running (events={events})"
|
|
)
|
|
assert events == [("kill", True)]
|
|
|
|
|
|
def test_drain_helper_handles_invalid_pid(monkeypatch):
|
|
"""_drain_gateway_pid returns False for invalid PIDs without crashing."""
|
|
assert gateway_windows._drain_gateway_pid(0, 5.0) is False
|
|
assert gateway_windows._drain_gateway_pid(-1, 5.0) is False
|
|
|
|
|
|
def test_drain_helper_returns_true_when_pid_exits_quickly(monkeypatch):
|
|
"""_drain_gateway_pid polls _pid_exists until it returns False."""
|
|
pid = 66666
|
|
poll_count = [0]
|
|
|
|
def fake_pid_exists(check_pid):
|
|
poll_count[0] += 1
|
|
return poll_count[0] < 3 # alive twice, then gone
|
|
|
|
from gateway import status as status_mod
|
|
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
|
monkeypatch.setattr(status_mod, "_pid_exists", fake_pid_exists)
|
|
|
|
assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True
|
|
|
|
|
|
def test_drain_helper_returns_false_on_timeout(monkeypatch):
|
|
"""_drain_gateway_pid returns False when the PID never exits."""
|
|
from gateway import status as status_mod
|
|
monkeypatch.setattr(status_mod, "write_planned_stop_marker", lambda p: True)
|
|
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: True)
|
|
|
|
assert gateway_windows._drain_gateway_pid(55555, drain_timeout=1.0) is False
|
|
|
|
|
|
def test_drain_helper_still_waits_if_marker_write_fails(monkeypatch):
|
|
"""Marker-write failures are swallowed; drain still polls for PID exit.
|
|
|
|
If the marker can't be written (disk full, permission error), the
|
|
gateway can't drain — but the wait still happens so a slow-shutdown
|
|
gateway from a different code path (e.g. SIGTERM working on this
|
|
platform after all) still gets observed cleanly.
|
|
"""
|
|
pid = 44444
|
|
def fake_write(target_pid):
|
|
raise OSError("disk full")
|
|
|
|
from gateway import status as status_mod
|
|
monkeypatch.setattr(status_mod, "write_planned_stop_marker", fake_write)
|
|
monkeypatch.setattr(status_mod, "_pid_exists", lambda check_pid: False)
|
|
|
|
# Returns True because _pid_exists immediately says "gone".
|
|
assert gateway_windows._drain_gateway_pid(pid, drain_timeout=5.0) is True |