fix(gateway): a named profile cannot install a new standalone gateway without --force

`hermes -p <name> gateway install|start` (and `run`, `restart`, the setup flows
through `ensure_gateway_service`) refused only while a live multiplexer already
served the profile. On a host with no multiplexer running -- or one that had not
rescanned yet -- the same command wrote a brand-new per-profile unit: a fleet
member the multiplex-only topology (#118273) no longer supports.

`_named_profile_refused_under_multiplexer` now refuses every
`<root>/profiles/<name>` home that has no service of its own: the served form
keeps naming the owner PID and `gateway restart`; the new form points at
`hermes gateway install` (default profile) and `hermes gateway migrate
--multiplex`. `--force` stays the single escape (UNIX-user / out-of-tree
HERMES_HOME fleets), and a `--force`-installed service stays startable without
it so its supervisor (ExecStart carries no --force) keeps relaunching it. The
dashboard `/api/gateway/start` twin (`multiplexed_profile_refusal`) applies the
same rule and text; `stop` is unchanged.

Tests: red on base for the no-multiplexer refusal (exit 78, no unit written,
A->B->A over two profile homes, dashboard shape); controls for the default's own
install, the served-by-PID form, and the --force path; the #111958 setup test
now asserts the unserved named profile gets no unit either.

Completes #118273 (item B). Part of #109417
This commit is contained in:
teknium1
2026-09-21 11:15:40 -07:00
committed by Teknium
parent c7c2df1a53
commit d27ba15669
4 changed files with 195 additions and 29 deletions

View File

@@ -3690,7 +3690,9 @@ def _served_profile_needs_no_service() -> bool:
Shared by ``hermes setup gateway`` / ``hermes setup`` / ``hermes import`` (``ensure_gateway_service``)
and the ``hermes gateway setup`` wizard. See #111958."""
if not named_profile_served_by_running_multiplexer():
return False
# Not served (yet): a named profile still gets no service of its own — same rule and text
# as `gateway install`, so `hermes -p X setup` cannot grow a fleet member the verb refuses.
return _named_profile_refused_under_multiplexer()
print_success(
f"Profile '{_current_profile_name()}' is already served by the default multiplexer."
)
@@ -3700,39 +3702,61 @@ def _served_profile_needs_no_service() -> bool:
def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
"""Print the served-profile refusal and return True when a named-profile gateway must not start:
a multiplexing default gateway already serves it (a second one would double-bind its platforms: two
pollers on one token, port fights). ``--force`` overrides. Shared by ``run`` and the service verbs
(``start``/``install``/``restart``): a refusal only inside ``gateway run`` leaves the service manager
to discover it — systemd parks the unit on exit 78 while the CLI prints "started"; launchd
(KeepAlive, no exit-status gating) respawns it every ThrottleInterval forever."""
"""Print the refusal and return True when a NAMED profile must not get a gateway of its own.
One gateway per host serves every profile, so a ``<root>/profiles/<name>`` home never installs or
starts a standalone gateway: either the host gateway already serves it (a second one would
double-bind its platforms: two pollers on one token, port fights) or no host gateway runs yet and
the DEFAULT profile is where it is installed. Refusing only the served case let a host with no
multiplexer running (or one that had not rescanned yet) grow a brand-new per-profile fleet member.
``--force`` is the one escape (a fleet split across UNIX users or a ``HERMES_HOME`` outside
``profiles/``); a service it already installed stays startable without it. Shared by ``run`` and the service verbs (``start``/``install``/``restart``): a
refusal only inside ``gateway run`` leaves the service manager to discover it — systemd parks the
unit on exit 78 while the CLI prints "started"; launchd (KeepAlive, no exit-status gating)
respawns it every ThrottleInterval forever."""
if force:
return False
try:
suffix = _current_profile_name()
from hermes_constants import profile_name_for_home
# A unit/plist/task already registered for this home was installed with --force: that fleet
# member (and the supervisor relaunching it, whose ExecStart carries no --force) is not NEW.
new_standalone = (profile_name_for_home(get_hermes_home()) not in (None, "default")
and not _is_service_installed())
except Exception:
return False
owner = _served_by_another_host_gateway()
if owner is None and not named_profile_served_by_running_multiplexer():
served = owner is not None or named_profile_served_by_running_multiplexer()
if not served and not new_standalone:
return False
print_error(
f"The host gateway already serves profile '{suffix}'."
)
if owner is not None:
print(f" {owner.describe()}")
if served:
print_error(f"The host gateway already serves profile '{suffix}'.")
if owner is not None:
print(f" {owner.describe()}")
else:
print_error(f"Profile '{suffix}' does not get a gateway of its own.")
print(
" Exactly one gateway per host is the inbound process for every\n"
" profile. Starting a separate gateway for this profile would\n"
" double-bind its platforms (two pollers on one bot token, port\n"
" conflicts).\n"
)
print(" Manage the host gateway instead:")
if served:
print(" Manage the host gateway instead:")
print()
print(f" hermes -p {owner.profile_label if owner is not None else 'default'} gateway restart")
else:
print(" Install or start the host gateway from the default profile; it serves this one too:")
print()
print(" hermes gateway install")
print()
print(" Or fold an existing per-profile fleet onto one host gateway:")
print()
print(" hermes gateway migrate --multiplex")
print()
print(f" hermes -p {owner.profile_label if owner is not None else 'default'} gateway restart")
print()
print(" Pass --force to start a separate profile gateway anyway (not")
print(" recommended while the host gateway is running).")
print(" A separate per-profile gateway (for a fleet split across UNIX users or a")
print(f" HERMES_HOME outside profiles/) needs --force: hermes -p {suffix} gateway install --force")
return True

View File

@@ -529,19 +529,33 @@ def _profile_is_multiplexed(profile: str) -> bool:
def multiplexed_profile_refusal(profile: Optional[str], verb: str) -> Optional[str]:
"""Refusal text for ``gateway start``/``stop`` on a profile the live default multiplexer serves and
that has no gateway of its own (a ``--force``-started separate one is managed normally), else None.
The spawned ``hermes -p X gateway <verb>`` would only print exit-78 / "no gateway running for this
profile" into an action log nobody reads while the UI shows the verb as done."""
"""Refusal text for ``gateway start``/``stop`` on a named profile with no gateway of its own (a
``--force``-started separate one is managed normally), else None. ``stop`` is refused only when the
live default multiplexer serves the profile; ``start`` is refused for every named profile — one
host gateway serves every profile, so a new per-profile gateway is never the answer (the CLI twin
``_named_profile_refused_under_multiplexer`` exits 78 into an action log nobody reads while the UI
shows the verb as done)."""
requested = _own_profile_selector(profile) or ""
if not requested or requested.lower() in {"current", "default"} or not _profile_is_multiplexed(requested):
if not requested or requested.lower() in {"current", "default"}:
return None
served = _profile_is_multiplexed(requested)
if not served and verb != "start":
return None
from hermes_cli.profiles import _check_gateway_running
from hermes_cli.web_server_profiles import _resolve_profile_dir
if _check_gateway_running(_resolve_profile_dir(requested)):
profile_dir = _resolve_profile_dir(requested)
if _check_gateway_running(profile_dir):
return None
return (f"The default gateway already serves profile '{requested}' as a multiplexer; "
f"{verb} it from the default profile instead of a separate gateway for this profile.")
if served:
return (f"The default gateway already serves profile '{requested}' as a multiplexer; "
f"{verb} it from the default profile instead of a separate gateway for this profile.")
from hermes_cli.gateway_migrate import _installed_services
if _installed_services(profile_dir):
return None # a --force-installed fleet member is not NEW; its own service is started normally
return (f"Profile '{requested}' does not get a gateway of its own: one host gateway serves every "
f"profile. Install or start it from the default profile (hermes gateway install), or fold an "
f"existing per-profile fleet with `hermes gateway migrate --multiplex`; "
f"`hermes -p {requested} gateway install --force` starts a separate one anyway.")
def _restart_gateway_after(profile: Optional[str], *, what: str, label: str) -> dict[str, Any]:

View File

@@ -103,8 +103,10 @@ def test_setup_wizard_skips_service_install_for_profile_served_by_multiplexer(
def test_setup_gateway_service_step_skips_install_for_served_profile(served_root, monkeypatch, capsys):
"""``hermes -p <profile> setup gateway`` (and ``hermes setup`` / ``hermes import``) reach the service
step through ``ensure_gateway_service``: a served profile gets the multiplexer note and no unit/plist,
while a profile the live record does not list is still installed (#111958)."""
step through ``ensure_gateway_service``: a served profile gets the multiplexer note and no unit/plist
(#111958), and a named profile the live record does not list gets no unit/plist either — one host
gateway serves every profile, so setup must not grow a standalone fleet member that
``gateway install`` refuses (#109417)."""
import hermes_cli.gateway as gw
calls: list[str] = []
@@ -121,7 +123,8 @@ def test_setup_gateway_service_step_skips_install_for_served_profile(served_root
monkeypatch.setenv("HERMES_HOME", str(served_root / "profiles" / "other")) # not in the live record
assert gw.ensure_gateway_service(context="setup") is True
assert calls == ["install", "start"]
assert calls == []
assert "Profile 'other' does not get a gateway of its own" in capsys.readouterr().out
def test_recycled_pid_does_not_lend_a_stale_record_its_served_profiles(served_root):

View File

@@ -0,0 +1,125 @@
"""A named profile cannot create a NEW standalone gateway — multiplexer running or not (#109417).
One gateway per host serves every profile. ``hermes -p X gateway install|start`` used to refuse only
while a live multiplexer already served X; on a host with no multiplexer running (or one that had not
rescanned yet) it wrote a brand-new per-profile unit. Now every named profile is refused without
``--force`` and pointed at ``hermes gateway install`` (default) / ``hermes gateway migrate --multiplex``;
``--force`` stays the one escape and a ``--force``-installed service stays startable without it. The
dashboard ``/api/gateway/start`` twin (``multiplexed_profile_refusal``) applies the same rule.
"""
from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
import pytest
@pytest.fixture
def quiet_host(tmp_path, monkeypatch):
"""Two named profiles under one root, no gateway running anywhere, systemd units under tmp."""
import hermes_cli.gateway as gw
import hermes_constants
root = tmp_path / "hermes"
(root / "config.yaml").parent.mkdir(parents=True)
(root / "config.yaml").write_text("model: {default: x}\n")
for name in ("coder", "ops"):
(root / "profiles" / name).mkdir(parents=True)
(root / "profiles" / name / "config.yaml").write_text("{}\n")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
monkeypatch.setattr(gw, "supports_systemd_services", lambda: True)
monkeypatch.setattr(gw, "_service_backend", lambda: "systemd")
monkeypatch.setattr(gw, "refuses_container_user_scope_install", lambda system: False)
monkeypatch.setattr(gw, "_dispatch_via_service_manager_if_s6", lambda v: False)
monkeypatch.setattr(gw, "is_managed", lambda: False)
monkeypatch.setattr(gw, "is_termux", lambda: False)
calls: list[str] = []
monkeypatch.setattr(gw, "_install_systemd_from_cli", lambda *a, **k: calls.append("install"))
monkeypatch.setattr(gw, "_service_call", lambda backend, v, system: calls.append(v))
def use(profile: str | None) -> None:
home = root if profile is None else root / "profiles" / profile
monkeypatch.setenv("HERMES_HOME", str(home))
hermes_constants._default_hermes_root_memo = None
return root, calls, use
def _run(fn, **ns):
out = io.StringIO()
with contextlib.redirect_stdout(out), pytest.raises(SystemExit) as exc:
fn(argparse.Namespace(system=False, all=False, run_as_user=None, **ns))
return exc.value.code, out.getvalue()
def test_named_profile_install_and_start_refuse_without_force_when_no_multiplexer_runs(quiet_host):
import hermes_cli.gateway as gw
from hermes_cli.web_server_gateway import multiplexed_profile_refusal
root, calls, use = quiet_host
# A -> B -> A: the refusal names the right profile from each home and installs nothing.
for profile in ("coder", "ops", "coder"):
use(profile)
for verb in ("install", "start"):
code, out = _run(getattr(gw, f"_cmd_{verb}"), force=False)
assert code == gw.GATEWAY_FATAL_CONFIG_EXIT_CODE, (profile, verb)
assert f"Profile '{profile}' does not get a gateway of its own" in out
assert "hermes gateway install" in out and "hermes gateway migrate --multiplex" in out
assert f"hermes -p {profile} gateway install --force" in out
assert "already serves" not in out # nothing is running: this is the no-multiplexer form
assert not gw.get_systemd_unit_path(system=False).exists()
# Dashboard twin: same rule, same pointers, `stop` untouched (nothing serves the profile).
refusal = multiplexed_profile_refusal(profile, "start")
assert refusal and f"'{profile}'" in refusal and "migrate --multiplex" in refusal and "--force" in refusal
assert multiplexed_profile_refusal(profile, "stop") is None
assert calls == []
# Control: the default profile's own install is unchanged.
use(None)
with contextlib.redirect_stdout(io.StringIO()):
gw._cmd_install(argparse.Namespace(system=False, all=False, run_as_user=None, force=False))
assert calls == ["install"]
# Control: a live multiplexer serving the profile still prints the served-by form.
use("coder")
(root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(root)}))
(root / "gateway_state.json").write_text(json.dumps(
{"pid": os.getpid(), "hermes_home": str(root), "gateway_state": "running",
"served_profiles": ["default", "coder"]}))
import gateway.status as status
real_cmdline = status._read_process_cmdline
status._read_process_cmdline = lambda pid: (
"python -m hermes_cli.main gateway run" if pid == os.getpid() else real_cmdline(pid))
try:
code, out = _run(gw._cmd_install, force=False)
finally:
status._read_process_cmdline = real_cmdline
assert code == gw.GATEWAY_FATAL_CONFIG_EXIT_CODE
assert "The host gateway already serves profile 'coder'" in out and "gateway restart" in out
def test_force_installs_a_separate_profile_gateway_and_its_service_stays_startable(quiet_host):
import hermes_cli.gateway as gw
from hermes_cli.web_server_gateway import multiplexed_profile_refusal
root, calls, use = quiet_host
use("coder")
with contextlib.redirect_stdout(io.StringIO()):
gw._cmd_install(argparse.Namespace(system=False, all=False, run_as_user=None, force=True))
assert calls == ["install"]
# The --force-installed fleet member is not NEW: its supervisor (ExecStart carries no --force)
# and a plain `gateway start` must keep reaching it, CLI and dashboard alike.
unit = gw.get_systemd_unit_path(system=False)
unit.parent.mkdir(parents=True)
unit.write_text("[Service]\n")
with contextlib.redirect_stdout(io.StringIO()):
gw._cmd_start(argparse.Namespace(system=False, all=False, run_as_user=None, force=False))
assert calls == ["install", "start"]
assert multiplexed_profile_refusal("coder", "start") is None