Files
hermes-agent/tests/hermes_cli/test_gateway_host_verbs.py
teknium1 4cb2c8ae76 fix(review): a named profile's restart --all never runs the root gateway in-process
MAJOR: `hermes -p X gateway restart --all` with no installed service re-entered
`run_gateway(replace=True)` IN the CLI process. `_home_env` swapped only HERMES_HOME
while os.environ already held X's dotenv (loaded at CLI startup) and gateway/run.py's
root .env load does not clear inherited keys, so the default gateway came up with X's
TELEGRAM_BOT_TOKEN / OPENAI_API_KEY. And any child spawned inside the swap (Windows
`start --all`, the launchd/detached fallback, migrate's secondary spawns) read the
swapped home as the launch profile, so `served_profile_child_env` neither stripped nor
scrubbed X's env.

- `_home_env` pins the process's launch home for the swap (`pin_process_hermes_home`),
  so routed-home decisions inside it keep the real identity; `strip_launch_profile_env`
  reads the residue list from that pinned home too.
- `_restart_all_as_host` spawns the root detached (`_spawn_detached_gateway`, scrubbed
  base env + the root's own secret scope, `gateway run --replace`) when the CLI runs
  under a named profile; the default profile keeps its foreground run. The Desktop
  update hand-off's `gateway start --all` under a named active profile is still served.
- test: `test_named_profile_restart_all_with_host_down_restarts_the_default_root` now
  asserts the child env carries none of the named profile's keys and `run_gateway` is
  never entered (red on the PR head: "the root ran IN the named profile's process").

MINORS:
- (a) `gateway_migrate._service_op('enable')` raises on a non-zero `systemctl enable`;
  a fresh apply treats it as a preflight refusal before the flag/manifest write, a
  resume reports it and converges. New `test_migrate_refuses_when_the_survivor_cannot_
  be_enabled` (red on head: exit 0 with the ⚠ line).
- (b) `systemd_install` already-current branch warns when `systemctl enable` fails.
- (c) `remove_system_systemd_unit` reports a failed `systemctl stop` instead of ✓.
2026-09-28 04:04:23 -07:00

331 lines
14 KiB
Python

"""``gateway start/restart --all`` and ``gateway run`` mean "the ONE host multiplexer".
Base behaviour: ``--all`` SIGTERMed every gateway process on the host — including a multiplexer
serving other profiles — and started a single gateway in its place, so a per-profile command
caused a host-wide outage that left exactly one profile served.
Also covered here, because each was proven dead or wrong on the PR head:
* a supervised attach exits 75 (retry), never 78 (park) — the observation is transient;
* ``--replace`` is not eaten by the CLI guard, so ``start_gateway`` can signal the owner;
* ``restart --all`` retracts the stopped owner's record and re-enters with ``replace=True``,
instead of attaching to the corpse it just stopped;
* ``-p X gateway restart --all`` reaches the ``--all``-aware branch.
The record is written as raw JSON so the file collects against a tree without the ``home`` field.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from gateway import host_attach
from gateway import host_rendezvous as hr
from hermes_cli import gateway as gw
def _reset_probe_memo() -> None:
"""Drop the host-gateway memo when the tree HAS one.
``getattr`` on purpose: a tree without the memo must still run these tests and fail on the
OUTCOME, never error at fixture setup — an import tripwire proves nothing about behaviour.
"""
getattr(host_attach, "invalidate_host_gateway_cache", lambda: None)()
@pytest.fixture(autouse=True)
def _no_memo():
"""The probe is memoized for a couple of seconds; each arrangement starts from cold."""
_reset_probe_memo()
yield
_reset_probe_memo()
def _write_record(pid: int, home: Path, profiles: tuple[str, ...]) -> None:
payload = {
"role": hr.ROLE_GATEWAY, "home": str(home), "pid": pid,
"createTime": hr.process_create_time(pid), "host": "", "port": None,
"protocolVersion": hr.HOST_PROTOCOL_VERSION, "tokenFingerprint": "",
"profiles": list(profiles), "updatedAt": "",
}
path = hr.record_path(hr.ROLE_GATEWAY)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
@pytest.fixture
def host_owner(tmp_path, monkeypatch):
"""A REAL live process published as the host gateway, answering identify for three profiles."""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
home = tmp_path / "root"
_write_record(child.pid, home, ("default", "ops", "coder"))
monkeypatch.setattr(
"gateway.control_socket.identify_gateway",
lambda dialled, **kw: {"pid": child.pid, "hermes_home": str(home),
"served_profiles": ["default", "ops", "coder"]})
try:
yield SimpleNamespace(pid=child.pid, home=home, proc=child)
finally:
child.terminate()
child.wait(timeout=10)
def test_start_all_never_sweeps_a_live_host_multiplexer(host_owner, monkeypatch, capsys):
def _never(*a, **k):
raise AssertionError("start --all SIGTERMed the host multiplexer")
monkeypatch.setattr(gw, "kill_gateway_processes", _never)
monkeypatch.setattr(
gw, "_guard_named_profile_under_multiplexer",
lambda **k: pytest.fail("the generic profile guard pre-empted start --all"),
)
monkeypatch.setattr(gw, "_dispatch_via_service_manager_if_s6", lambda verb: False)
monkeypatch.setattr(gw, "_service_backend", lambda: None)
monkeypatch.setattr(gw, "find_gateway_pids", lambda **k: [])
gw._cmd_start(SimpleNamespace(system=False, all=True, force=False))
out = capsys.readouterr().out
assert "already running" in out
# The served set survives the verb: all three profiles are still reported as served.
for profile in ("default", "ops", "coder"):
assert profile in out
def test_named_profile_start_all_starts_the_default_host_service(monkeypatch, tmp_path):
"""With no live owner, `-p X gateway start --all` must start the host root, never X's gateway.
Skipping the generic named-profile guard alone is insufficient on Windows: the Windows backend
derives its Scheduled Task name, launcher HERMES_HOME and detached child from the active home.
"""
import hermes_constants
root = tmp_path / "hermes"
named = root / "profiles" / "leinad"
root.mkdir()
named.mkdir(parents=True)
(root / "config.yaml").write_text("{}\n", encoding="utf-8")
(named / "config.yaml").write_text("{}\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(named))
hermes_constants._default_hermes_root_memo = None
monkeypatch.setattr(
gw, "_guard_named_profile_under_multiplexer",
lambda **k: pytest.fail("the generic profile guard pre-empted start --all"),
)
monkeypatch.setattr(gw, "_host_multiplexer_for_all_verb", lambda: None)
monkeypatch.setattr(gw, "_service_mgmt_blocked", lambda: False)
monkeypatch.setattr(gw, "_service_backend", lambda: "windows")
calls = []
def _observe(step):
calls.append((
step,
Path(hermes_constants.get_hermes_home()),
os.environ.get("HERMES_HOME"),
))
def _kill(**kwargs):
assert kwargs == {"all_profiles": True}
_observe("kill")
return 1
monkeypatch.setattr(gw, "kill_gateway_processes", _kill)
monkeypatch.setattr(gw, "_wait_for_gateway_exit",
lambda **k: _observe("wait") or True)
monkeypatch.setattr(gw, "_wait_for_api_server_port_free",
lambda **k: _observe("port") or True)
def _windows_start():
_observe("start")
# Keep the real _service_call dispatch in the path; only replace the OS-specific leaf.
monkeypatch.setattr(gw, "_gw_windows", lambda: SimpleNamespace(start=_windows_start))
gw._cmd_start(SimpleNamespace(system=False, all=True, force=False))
assert calls == [
("kill", root, str(root)),
("wait", root, str(root)),
("port", root, str(root)),
("start", root, str(root)),
]
# The CLI's named-profile scope is restored after the host-scoped operation.
assert hermes_constants.get_hermes_home() == named
assert os.environ["HERMES_HOME"] == str(named)
def test_named_profile_restart_all_with_host_down_restarts_the_default_root(monkeypatch, tmp_path):
"""No live owner: `-p X gateway restart --all` must relaunch the host root, never X's gateway.
Post-update the host is down, so the ownership refusal cannot fire; the sweep-and-relaunch
then ran as X, whose own gateway the run-side named-profile guard refuses (exit 78).
And it must relaunch the root as a DETACHED child carrying only the root's own secrets: X's
CLI already loaded X's ``.env`` into ``os.environ``, and an in-process ``run_gateway`` served the
default profile with X's credentials.
"""
import hermes_constants
root = tmp_path / "hermes"
named = root / "profiles" / "leinad"
named.mkdir(parents=True)
(root / "config.yaml").write_text("{}\n", encoding="utf-8")
(named / "config.yaml").write_text("{}\n", encoding="utf-8")
(root / ".env").write_text("DISCORD_BOT_TOKEN=root-discord\n", encoding="utf-8")
(named / ".env").write_text("TELEGRAM_BOT_TOKEN=leinad-telegram\nOPENAI_API_KEY=leinad-openai\n",
encoding="utf-8")
# The CLI startup already loaded leinad's .env into this process.
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "leinad-telegram")
monkeypatch.setenv("OPENAI_API_KEY", "leinad-openai")
monkeypatch.setenv("HERMES_HOME", str(named))
hermes_constants._default_hermes_root_memo = None
monkeypatch.setattr(gw, "_host_multiplexer_for_all_verb", lambda: None)
monkeypatch.setattr(gw, "_stop_installed_service", lambda system: False)
monkeypatch.setattr(gw, "kill_gateway_processes", lambda **k: 0)
monkeypatch.setattr(gw, "_wait_for_gateway_exit", lambda **k: True)
monkeypatch.setattr(gw, "_wait_for_api_server_port_free", lambda **k: True)
monkeypatch.setattr(gw, "_discard_dead_host_record", lambda: False)
monkeypatch.setattr(gw, "_installed_service_kind_for", lambda windows: None)
monkeypatch.setattr(gw, "run_gateway",
lambda **k: pytest.fail("the root ran IN the named profile's process"))
monkeypatch.setattr(gw, "get_python_path", lambda: sys.executable)
spawned: list[tuple[list[str], dict]] = []
monkeypatch.setattr(gw.subprocess, "Popen",
lambda cmd, **kw: spawned.append((cmd, kw)) or SimpleNamespace())
gw._restart_all(system=False)
assert len(spawned) == 1
cmd, kwargs = spawned[0]
assert cmd[-3:] == ["gateway", "run", "--replace"]
assert "--profile" not in cmd
env = kwargs["env"]
assert Path(env["HERMES_HOME"]) == root
assert env.get("DISCORD_BOT_TOKEN") == "root-discord"
assert "TELEGRAM_BOT_TOKEN" not in env and "OPENAI_API_KEY" not in env, \
"the host child inherited the named profile's credentials"
assert hermes_constants.get_hermes_home() == named
assert not hermes_constants.process_hermes_home_is_pinned()
def test_restart_all_refuses_to_sweep_a_host_multiplexer_owned_by_another_profile(
host_owner, monkeypatch, tmp_path):
def _never(*a, **k):
raise AssertionError("restart --all SIGTERMed another profile's host multiplexer")
monkeypatch.setattr(gw, "kill_gateway_processes", _never)
monkeypatch.setattr(gw, "_stop_installed_service", lambda system: False)
monkeypatch.setattr("gateway.status._get_process_hermes_home",
lambda: Path(tmp_path / "root" / "profiles" / "ops"))
with pytest.raises(SystemExit) as exc:
gw._restart_all(system=False)
assert exc.value.code == gw.GATEWAY_FATAL_CONFIG_EXIT_CODE
def test_restart_all_does_not_attach_to_the_process_it_just_stopped(
host_owner, monkeypatch, tmp_path):
"""After a confirmed stop the record is retracted and the re-entry carries ``replace``.
Without both, the re-entered ``gateway run`` read the corpse's record, decided ATTACH and
exited 0 — a restart that silently left the host with no gateway at all.
"""
monkeypatch.setattr("gateway.status._get_process_hermes_home", lambda: host_owner.home)
monkeypatch.setattr(gw, "_stop_installed_service", lambda system: False)
monkeypatch.setattr(gw, "kill_gateway_processes", lambda **k: 1)
monkeypatch.setattr(gw, "_wait_for_api_server_port_free", lambda *a, **k: None)
monkeypatch.setattr(gw, "_installed_service_kind_for", lambda *a, **k: None)
monkeypatch.setattr(gw, "_print_unfolded_gateway_note", lambda owner: None)
def _stopped(*a, **k):
# The owner really is gone by the time the re-entry happens.
host_owner.proc.kill()
host_owner.proc.wait(timeout=10)
_reset_probe_memo()
monkeypatch.setattr(gw, "_wait_for_gateway_exit", _stopped)
reentry: list[dict] = []
monkeypatch.setattr(gw, "run_gateway", lambda **kw: reentry.append(kw))
gw._restart_all(system=False)
assert hr.read_record(hr.ROLE_GATEWAY, include_stale=True) is None, \
"the stopped owner's record must be retracted"
assert reentry and reentry[0].get("replace") is True
def test_named_profile_restart_all_reaches_the_all_aware_branch(monkeypatch):
"""The generic guard ran first and printed a bare `gateway restart` (no --all)."""
monkeypatch.setattr(gw, "_refuse_from_inside_gateway", lambda *a, **k: None)
monkeypatch.setattr(gw, "_dispatch_all_via_service_manager_if_s6", lambda verb: False)
monkeypatch.setattr(gw, "_guard_named_profile_under_multiplexer",
lambda **k: pytest.fail("the generic guard pre-empted the --all branch"))
called: list[bool] = []
monkeypatch.setattr(gw, "_restart_all", lambda system: called.append(True))
gw._cmd_restart(SimpleNamespace(system=False, all=True, force=False))
assert called == [True]
def test_a_supervised_attach_is_retried_not_parked(monkeypatch, capsys):
"""78 parks the unit for good; "someone serves me right now" is a transient observation."""
owner = host_attach.HostGateway(4321, Path("/somewhere"), ("default", "other"))
monkeypatch.setattr(
"gateway.host_attach.decide",
lambda home, replace=False: host_attach.HostAttachDecision(
host_attach.ATTACH, "attached", owner, transient=True))
monkeypatch.setattr(gw, "_running_under_gateway_supervisor", lambda: True)
with pytest.raises(SystemExit) as exc:
gw._attach_to_host_gateway_or_guard(force=False)
assert exc.value.code == gw.GATEWAY_SERVICE_RESTART_EXIT_CODE
assert exc.value.code != gw.GATEWAY_FATAL_CONFIG_EXIT_CODE
def test_a_refusal_lands_in_the_profile_logs_not_only_on_stdout(monkeypatch, caplog):
"""The refusal's stdout goes to the supervisor's unit log; launchd then maps 78 to a clean exit and
parks the unit. Nothing in the profile's own logs said why (field report on #118097)."""
owner = host_attach.HostGateway(4321, Path("/somewhere"), ("default",))
monkeypatch.setattr(
"gateway.host_attach.decide",
lambda home, replace=False: host_attach.HostAttachDecision(
host_attach.REFUSE, host_attach._refuse_message(owner, "nous"), owner))
with caplog.at_level("WARNING", logger="hermes_cli.gateway"), pytest.raises(SystemExit) as exc:
gw._attach_to_host_gateway_or_guard(force=False)
assert exc.value.code == gw.GATEWAY_FATAL_CONFIG_EXIT_CODE
warned = [r for r in caplog.records if r.levelno >= 30 and "migrate --multiplex" in r.getMessage()]
assert warned, "a refusal must leave the remedy in the profile's own log"
def test_replace_is_not_eaten_by_the_cli_guard(monkeypatch):
"""The guard exited before ``start_gateway`` ever saw ``--replace``, so nothing was replaced."""
owner = host_attach.HostGateway(4321, Path("/somewhere"), ("default", "other"))
seen: list[bool] = []
def _decide(home, replace=False):
seen.append(replace)
return host_attach.HostAttachDecision(
host_attach.REPLACE_HOST if replace else host_attach.ATTACH, "", owner)
monkeypatch.setattr("gateway.host_attach.decide", _decide)
monkeypatch.setattr(gw, "_guard_named_profile_under_multiplexer",
lambda **k: pytest.fail("--replace must not fall through to the guard"))
gw._attach_to_host_gateway_or_guard(force=False, replace=True) # must not raise SystemExit
assert seen == [True], "the CLI guard must pass --replace into the host decision"