From 205645ee424163c7b6cfc032c331c3557797497b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:10:40 -0700 Subject: [PATCH] fix(gateway): register gateway.multiplex_profiles; explicit migrate --multiplex flips it with no standalone secondary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes config set gateway.multiplex_profiles true` warned "not a recognized config key" although gateway/config.py reads it: the key (and profile_routes) were never in DEFAULT_CONFIG["gateway"]. Both are registered with their doc comment; the CLI loaders deep-merge new keys, so no _config_version bump. `hermes gateway migrate --multiplex` with two or more profiles but no secondary running its own gateway printed "nothing to migrate" and left the flag OFF. The explicit command now applies the one remaining step — flag on, default gateway (re)started, the same rollback manifest (empty secondaries) for --standalone. `hermes update`'s automatic hook keeps treating that case as a no-op: it never flips modes on an install where nothing was running. --- hermes_cli/config_defaults.py | 13 ++++++++ hermes_cli/gateway_migrate.py | 32 ++++++++++++------- tests/hermes_cli/test_config.py | 10 ++++++ .../test_gateway_migrate_multiplex.py | 28 ++++++++++++++++ .../docs/user-guide/multi-profile-gateways.md | 18 ++++++++++- 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 7f850a5423..5273d0beb3 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1946,6 +1946,19 @@ DEFAULT_CONFIG = { # (primary copy: state.db gateway_routing table). True for external tooling and downgrade # safety; False stops producing the file. "write_sessions_json": True, + # One gateway for every profile on this host: the DEFAULT profile's gateway also connects + # each named profile's bots (their own .env / config.yaml, per-profile secret scope) and + # stamps the profile into session keys. Flip with `hermes gateway migrate --multiplex` + # (records a rollback manifest; `--standalone` undoes it) or `hermes config set + # gateway.multiplex_profiles true` + `hermes gateway restart`. GATEWAY_MULTIPLEX_PROFILES + # in the environment overrides. Two profiles configuring the same bot token cannot be + # served together — the duplicate adapter is parked; `hermes profile create --clone` + # therefore leaves messaging channels behind unless --clone-channels is passed. + "multiplex_profiles": False, + # Route inbound chats of the default profile's bots to another profile + # (gateway/profile_routing.py): [{profile, platform, chat_id|user_id|guild_id|...}]. + # Most-specific match wins; only read by the multiplexing default gateway. + "profile_routes": [], # Scale-to-zero idle TIMEOUT only. When an instance is opted in via the NAS "Labs" toggle # (HERMES_SCALE_TO_ZERO env stamp) AND messaging is relay-only/absent AND a wakeUrl is # registered, the relay transport goes dormant so the platform (e.g. Fly autostop) can diff --git a/hermes_cli/gateway_migrate.py b/hermes_cli/gateway_migrate.py index a2a127d113..fa0b831893 100644 --- a/hermes_cli/gateway_migrate.py +++ b/hermes_cli/gateway_migrate.py @@ -109,7 +109,9 @@ class MigrationPlan: } def eligible_for_migration(self) -> bool: - """>= 2 profiles, at least one secondary with its own gateway, multiplex off, no blockers.""" + """>= 2 profiles, at least one secondary with its own gateway, multiplex off, no blockers. + This is the AUTO-migration (``hermes update``) bar; the explicit command also proceeds with + zero standalone secondaries (see :func:`cmd_migrate`).""" return ( len(self.profiles) >= 2 and bool(self.standalone_secondaries) and not self.already_multiplexed and not self.blocked @@ -434,14 +436,22 @@ def format_plan(plan: MigrationPlan, *, dry_run: bool) -> list[str]: for p in plan.standalone_secondaries: what = " + ".join(x for x in (f"stop pid {p.pid}" if p.pid else "", f"uninstall {p.service_label()}" if p.service else "") if x) steps.append(f" - {p.name}: {what}") + if len(plan.profiles) < 2: # the notice already says "only one profile exists" + return lines + _plan_tail(plan) if not steps: - lines.append(" No secondary profile runs its own gateway; nothing to migrate.") + lines.append(" No secondary profile runs its own gateway; the only step is turning the flag on:") else: - lines += [" Steps:", *steps, f" - default: set gateway.multiplex_profiles: true in {plan.default_home / 'config.yaml'}"] - target = plan.target_service_kind() - lines.append(f" - default: {'restart' if plan.default.has_gateway else 'start'} the gateway" - + (f" via {target[0]}" if target else " (detached)") + f", verify it serves {len(plan.profiles)} profiles") - lines.append(f" - record removed services in {plan.default_home / MANIFEST_NAME} (rollback: hermes gateway migrate --standalone)") + lines += [" Steps:", *steps] + lines.append(f" - default: set gateway.multiplex_profiles: true in {plan.default_home / 'config.yaml'}") + target = plan.target_service_kind() + lines.append(f" - default: {'restart' if plan.default.has_gateway else 'start'} the gateway" + + (f" via {target[0]}" if target else " (detached)") + f", verify it serves {len(plan.profiles)} profiles") + lines.append(f" - record the previous state in {plan.default_home / MANIFEST_NAME} (rollback: hermes gateway migrate --standalone)") + return lines + _plan_tail(plan) + + +def _plan_tail(plan: MigrationPlan) -> list[str]: + lines: list[str] = [] if plan.blockers: lines += ["", " ✗ Blockers (fix these first, nothing will be changed):"] lines += [f" • {b}" for b in plan.blockers] @@ -635,10 +645,10 @@ def cmd_migrate(args) -> None: return if plan.already_multiplexed: return - if plan.blocked: - sys.exit(1) - if not plan.standalone_secondaries: - return + if plan.blocked or len(plan.profiles) < 2: + sys.exit(1 if plan.blocked else 0) + # Zero standalone secondaries is still a migration when the user asks for it explicitly: the flag + # goes on and the default gateway restarts (the update hook keeps treating that case as a no-op). if not getattr(args, "yes", False) and sys.stdin.isatty(): from hermes_cli.setup import prompt_yes_no if not prompt_yes_no("Apply this migration now?", True): diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 922a82d46b..1b64860cf5 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1910,3 +1910,13 @@ class TestConfigCommandFailClosedSurface: assert excinfo.value.code == 1 assert "not valid YAML" in capsys.readouterr().err assert config_path.read_text(encoding="utf-8") == original + + +def test_gateway_multiplex_keys_are_recognized_config_keys(): + """``hermes config set gateway.multiplex_profiles true`` used to warn 'not a recognized config + key' although gateway/config.py reads it; the key (and profile_routes) live in DEFAULT_CONFIG.""" + from hermes_cli.config import _validate_config_key + from hermes_cli.config_defaults import DEFAULT_CONFIG + assert DEFAULT_CONFIG["gateway"]["multiplex_profiles"] is False + assert _validate_config_key("gateway.multiplex_profiles") == (True, None) + assert _validate_config_key("gateway.profile_routes") == (True, None) diff --git a/tests/hermes_cli/test_gateway_migrate_multiplex.py b/tests/hermes_cli/test_gateway_migrate_multiplex.py index 520f34cadd..34cdef338f 100644 --- a/tests/hermes_cli/test_gateway_migrate_multiplex.py +++ b/tests/hermes_cli/test_gateway_migrate_multiplex.py @@ -171,3 +171,31 @@ def test_update_hook_never_touches_single_profile_or_already_multiplexed(fleet, fleet.services.clear(); fleet.pids.clear() # secondaries exist but run no gateway of their own gm.maybe_auto_migrate_after_update() assert capsys.readouterr().out == "" and _config_flag(fleet.root) is None + + +def test_explicit_migrate_with_no_standalone_secondaries_still_flips_flag_and_restarts_default(fleet, capsys, monkeypatch): + """The user typed --multiplex: 'nothing to migrate' + flag left off was a no-op the user did not ask + for. The update hook keeps its no-op (previous test); the explicit command proceeds.""" + fleet.services.clear(); fleet.pids.clear() + + def _detached(home): # no service manager anywhere -> detached start writes the served record + fleet.services["default-detached"] = True + (fleet.root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(fleet.root)})) + (fleet.root / "gateway_state.json").write_text(json.dumps({ + "pid": os.getpid(), "hermes_home": str(fleet.root), "gateway_state": "running", + "served_profiles": ["default", "coder", "ops"]})) + return True + monkeypatch.setattr(gm, "_spawn_detached_gateway", _detached) + assert not gm.build_migration_plan().standalone_secondaries + with pytest.raises(SystemExit) as exc: + gm.cmd_migrate(SimpleNamespace(multiplex=True, standalone=False, dry_run=False, yes=True)) + assert exc.value.code == 0 + assert fleet.services.pop("default-detached") is True + assert _config_flag(fleet.root) is True + manifest = json.loads((fleet.root / gm.MANIFEST_NAME).read_text(encoding="utf-8")) + assert manifest["secondaries"] == [] and manifest["flag_was"] is False + assert ("default", "install") not in fleet.ops + out = capsys.readouterr().out + assert "serves 3 profiles" in out + # The same manifest rolls it back: flag restored, nothing to reinstall. + assert gm.rollback_migration(fleet.root) is True and _config_flag(fleet.root) is False diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index b3045093bd..855abc3879 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -802,7 +802,23 @@ preflight: fix and the one-liner to run later. Nothing is changed. Single-profile installs are never migrated (there is nothing to gain), and an -install that is already multiplexing is left alone. +install that is already multiplexing is left alone. `hermes update` also does +nothing when no secondary profile runs its own gateway — it never flips modes +on an install where nothing was running. + +The explicit command is different: `hermes gateway migrate --multiplex` with +two or more profiles and **no** standalone secondary gateway still applies the +one remaining step — it sets `gateway.multiplex_profiles: true`, (re)starts the +default gateway and writes the same rollback manifest (with an empty +`secondaries` list), so `--standalone` undoes it. You asked for multiplex; you +get multiplex. + +:::tip Clones do not carry channels +`hermes profile create --clone` leaves the source's bot tokens and allowlists +behind (see [Profiles → messaging channels are never cloned](./profiles.md#messaging-channels-are-never-cloned---clone-channels-to-opt-in)), +so a fleet of clones no longer trips the duplicate-credential blocker below. +Older clones that still carry them are flagged by `hermes profile list`. +::: ### What the migration does