Files
hermes-agent/tests/hermes_cli/test_launchd_install_start_now.py
John Paul Soliva 693e4aa12c fix(gateway): launchd install honours --no-start-now and the wizard's "start now" answer
On macOS, `hermes gateway install --no-start-now` started the gateway anyway.
`_cmd_install` forwarded the start flags to the systemd and Windows backends but
called `launchd_install(force)` alone, and `launchd_install` always ran
`launchctl bootstrap`. The plist sets RunAtLoad, so bootstrapping it starts the
gateway immediately, and the command still printed "Service installed and
loaded!". The setup wizard had the same gap: answering No to "Start the gateway
now?" and Yes to login auto-start still called `launchd_install(force=False)` and
started the gateway on the spot.

launchd_install now takes start_now. When it is False and launchd is not already
running the gateway, the install writes the plist and does not load it. It also
boots out any idle registration left from before, such as a job parked after a
clean exit, because `hermes gateway start` would kickstart that registration's
old definition instead of loading the new plist. The outdated-plist repair takes
the same path, since its bootout/bootstrap reload would start a stopped gateway.
A gateway that launchd already runs is reloaded as before, not stopped. With
the plist in ~/Library/LaunchAgents, the gateway starts at the next login or on
`hermes gateway start`. `_cmd_install` and the wizard now pass the answer
through.

Measured with launchctl recorded rather than run: `install --no-start-now` went
from 1 bootstrap to 0, the wizard's No went from 1 bootstrap to 0, and the
repair of an outdated plist with a stopped gateway went from a reload to a
rewrite only. The --no-start-on-login half on launchd is #91549 and is not
touched here.
2026-09-23 07:48:32 -07:00

100 lines
4.2 KiB
Python

"""`hermes gateway install --no-start-now` and the setup wizard's "Start the gateway now?" = No on launchd.
The plist carries RunAtLoad, so loading it (`launchctl bootstrap`) starts the gateway on the spot. A
no-start install must therefore write the plist without loading it; `hermes gateway start` loads it later.
"""
import argparse
import plistlib
import subprocess
from types import SimpleNamespace
import pytest
import hermes_cli.gateway as gateway_cli
from hermes_cli.subcommands.gateway import build_gateway_parser
LABEL = "ai.hermes.gateway"
DOMAIN = "gui/501"
def _parse(*argv):
parser = argparse.ArgumentParser()
build_gateway_parser(
parser.add_subparsers(dest="command"),
cmd_gateway=lambda a: None, cmd_proxy=lambda a: None, cmd_gateway_enroll=lambda a: None,
)
return parser.parse_args(["gateway", *argv])
@pytest.fixture
def launchd(tmp_path, monkeypatch):
"""launchd backend with every launchctl call recorded instead of run."""
state = SimpleNamespace(
plist=tmp_path / "LaunchAgents" / f"{LABEL}.plist",
supervised_pid=None, bootstraps=[], launchctl=[], refreshes=[], starts=[],
)
monkeypatch.setattr(gateway_cli, "_service_backend", lambda *a, **k: "launchd")
monkeypatch.setattr(gateway_cli, "is_managed", lambda: False)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "_guard_named_profile_under_multiplexer", lambda **k: None)
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: state.plist)
monkeypatch.setattr(gateway_cli, "get_launchd_label", lambda: LABEL)
monkeypatch.setattr(gateway_cli, "_launchd_domain", lambda: DOMAIN)
monkeypatch.setattr(gateway_cli, "_refuse_temp_home_service_write", lambda *a: False)
monkeypatch.setattr(gateway_cli, "_clear_launchd_unsupported_marker", lambda: None)
monkeypatch.setattr(gateway_cli, "_launchctl_supervised_pid", lambda label: state.supervised_pid)
monkeypatch.setattr(gateway_cli, "_launchctl_bootstrap", lambda *a, **k: state.bootstraps.append(a))
monkeypatch.setattr(gateway_cli, "refresh_launchd_plist_if_needed", lambda: state.refreshes.append(1) or True)
monkeypatch.setattr(gateway_cli, "_setup_service_action", lambda *a, **k: state.starts.append(a))
def fake_run(cmd, *a, **k):
state.launchctl.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
return state
BOOTOUT = ["launchctl", "bootout", f"{DOMAIN}/{LABEL}"]
@pytest.mark.parametrize(
"stale_plist, supervised_pid, reloads",
[(False, None, False), (True, None, False), (True, 4242, True)],
ids=["fresh-install", "repair-stopped-gateway", "repair-running-gateway"],
)
def test_cli_no_start_now_loads_only_a_gateway_launchd_already_runs(
launchd, capsys, stale_plist, supervised_pid, reloads,
):
"""Nothing running: the plist is written, never loaded, and an idle registration left from before
(a parked clean exit) is booted out, since that is what `gateway start` would kickstart. A gateway
launchd already runs is reloaded as before, not stopped."""
if stale_plist:
launchd.plist.parent.mkdir(parents=True)
launchd.plist.write_text("<plist>old</plist>", encoding="utf-8")
launchd.supervised_pid = supervised_pid
gateway_cli.gateway_command(_parse("install", "--no-start-now"))
assert launchd.bootstraps == []
if reloads:
assert launchd.refreshes == [1]
assert BOOTOUT not in launchd.launchctl
else:
assert launchd.refreshes == []
assert BOOTOUT in launchd.launchctl
assert plistlib.loads(launchd.plist.read_bytes())["Label"] == LABEL
assert "not started" in capsys.readouterr().out
def test_wizard_no_to_start_now_installs_without_loading(launchd, monkeypatch):
answers = iter([False, True]) # "Start the gateway now?" No, "on login" Yes
monkeypatch.setattr(gateway_cli, "prompt_yes_no", lambda *a, **k: next(answers))
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
gateway_cli._wizard_install_service("launchd")
assert launchd.plist.exists()
assert launchd.bootstraps == []
assert launchd.starts == []