diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 4934d312a1..e9bb7cc6c8 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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 ``/profiles/`` 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 diff --git a/hermes_cli/web_server_gateway.py b/hermes_cli/web_server_gateway.py index 41c5c14354..be43cc79dc 100644 --- a/hermes_cli/web_server_gateway.py +++ b/hermes_cli/web_server_gateway.py @@ -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 `` 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]: diff --git a/tests/hermes_cli/test_gateway_multiplex_served_record.py b/tests/hermes_cli/test_gateway_multiplex_served_record.py index 2996858336..c16cb765fa 100644 --- a/tests/hermes_cli/test_gateway_multiplex_served_record.py +++ b/tests/hermes_cli/test_gateway_multiplex_served_record.py @@ -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 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): diff --git a/tests/hermes_cli/test_gateway_no_new_standalone_profile.py b/tests/hermes_cli/test_gateway_no_new_standalone_profile.py new file mode 100644 index 0000000000..50012655fb --- /dev/null +++ b/tests/hermes_cli/test_gateway_no_new_standalone_profile.py @@ -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