mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-05 07:41:39 +00:00
fix(gateway): harden Windows gateway install lifecycle
Preserve Windows profile install decisions across UAC handoff, avoid visible console windows by launching via pythonw, make repeated install/start idempotent, recreate stale Scheduled Tasks, and separate start-now from login auto-start behavior. Add Windows gateway regression coverage and systemd setup tests for the shared install flow.
This commit is contained in:
parent
95683c0283
commit
d948de39e9
5 changed files with 914 additions and 54 deletions
|
|
@ -237,11 +237,13 @@ def test_gateway_install_in_container_with_operational_systemd_uses_systemd(monk
|
|||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(gateway, "prompt_yes_no", lambda question, default=True: calls.append(("prompt", question, default)) or True)
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"systemd_install",
|
||||
lambda force=False, system=False, run_as_user=None: calls.append((force, system, run_as_user)),
|
||||
lambda force=False, system=False, run_as_user=None, enable_on_startup=True: calls.append(("install", force, system, run_as_user, enable_on_startup)),
|
||||
)
|
||||
monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(("start", system)))
|
||||
|
||||
args = SimpleNamespace(
|
||||
gateway_command="install",
|
||||
|
|
@ -251,7 +253,12 @@ def test_gateway_install_in_container_with_operational_systemd_uses_systemd(monk
|
|||
)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
assert calls == [(False, False, None)]
|
||||
assert calls == [
|
||||
("prompt", "Start the gateway now after installing the service?", True),
|
||||
("prompt", "Start the gateway automatically on login/boot with systemd?", True),
|
||||
("install", False, False, None, True),
|
||||
("start", False),
|
||||
]
|
||||
|
||||
|
||||
def test_gateway_start_in_container_with_operational_systemd_uses_systemd(monkeypatch):
|
||||
|
|
@ -386,6 +393,34 @@ def test_systemd_install_checks_linger_status(monkeypatch, tmp_path, capsys):
|
|||
assert "User service installed and enabled" in out
|
||||
|
||||
|
||||
def test_systemd_install_can_skip_enable_on_startup(monkeypatch, tmp_path, capsys):
|
||||
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
|
||||
|
||||
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
|
||||
calls = []
|
||||
helper_calls = []
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
calls.append((cmd, check))
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(gateway, "_ensure_user_systemd_env", lambda: None)
|
||||
monkeypatch.setattr(gateway, "_ensure_linger_enabled", lambda: helper_calls.append(True))
|
||||
|
||||
gateway.systemd_install(force=False, enable_on_startup=False)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert unit_path.exists()
|
||||
assert [cmd for cmd, _ in calls] == [
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
]
|
||||
assert helper_calls == [True]
|
||||
assert "User service installed!" in out
|
||||
assert "installed and enabled" not in out
|
||||
|
||||
|
||||
def test_systemd_install_system_scope_skips_linger_and_uses_systemctl(monkeypatch, tmp_path, capsys):
|
||||
unit_path = tmp_path / "etc" / "systemd" / "system" / "hermes-gateway.service"
|
||||
|
||||
|
|
@ -466,13 +501,55 @@ def test_install_linux_gateway_from_setup_system_choice_as_root_installs(monkeyp
|
|||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"systemd_install",
|
||||
lambda force=False, system=False, run_as_user=None: calls.append((force, system, run_as_user)),
|
||||
lambda force=False, system=False, run_as_user=None, enable_on_startup=True: calls.append((force, system, run_as_user, enable_on_startup)),
|
||||
)
|
||||
|
||||
scope, did_install = gateway.install_linux_gateway_from_setup(force=True)
|
||||
|
||||
assert (scope, did_install) == ("system", True)
|
||||
assert calls == [(True, True, "alice")]
|
||||
assert calls == [(True, True, "alice", True)]
|
||||
|
||||
|
||||
def test_install_linux_gateway_from_setup_passes_startup_choice(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "prompt_linux_gateway_install_scope", lambda: "user")
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"systemd_install",
|
||||
lambda force=False, system=False, run_as_user=None, enable_on_startup=True: calls.append((force, system, run_as_user, enable_on_startup)),
|
||||
)
|
||||
|
||||
scope, did_install = gateway.install_linux_gateway_from_setup(force=False, enable_on_startup=False)
|
||||
|
||||
assert (scope, did_install) == ("user", True)
|
||||
assert calls == [(False, False, None, False)]
|
||||
|
||||
|
||||
def test_gateway_install_can_decline_start_now_and_startup(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_managed", lambda: False)
|
||||
|
||||
answers = iter([False, False])
|
||||
calls = []
|
||||
monkeypatch.setattr(gateway, "prompt_yes_no", lambda question, default=True: calls.append(("prompt", question, default)) or next(answers))
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"systemd_install",
|
||||
lambda force=False, system=False, run_as_user=None, enable_on_startup=True: calls.append(("install", force, system, run_as_user, enable_on_startup)),
|
||||
)
|
||||
monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(("start", system)))
|
||||
|
||||
args = SimpleNamespace(gateway_command="install", force=True, system=False, run_as_user=None)
|
||||
gateway.gateway_command(args)
|
||||
|
||||
assert calls == [
|
||||
("prompt", "Start the gateway now after installing the service?", True),
|
||||
("prompt", "Start the gateway automatically on login/boot with systemd?", True),
|
||||
("install", True, False, None, False),
|
||||
]
|
||||
|
||||
|
||||
def test_find_gateway_pids_falls_back_to_pid_file_when_process_scan_fails(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
"""Tests for the Windows gateway backend."""
|
||||
"""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(
|
||||
|
|
@ -61,3 +65,420 @@ def test_build_gateway_argv_uses_base_pythonw_for_uv_venv_launcher(monkeypatch,
|
|||
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())
|
||||
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue