From 5f1991bf6b1a2409a98c5591a2eefe7143b72c9c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:11:16 -0700 Subject: [PATCH] fix(gateway): import PairingStore in _start_secondary_profile_adapters (#65118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The served-profiles block in _start_secondary_profile_adapters references PairingStore, but the class's only import in gateway/run.py is method-local inside __init__ — so the reference raised NameError at runtime, silently swallowed by the enclosing try/except ('could not record served_profiles'). Result: multiplexing gateways never created per-profile pairing stores, and authz pairing checks for secondary profiles fell through to the global whitelist. Also masked the served_profiles runtime-status write. One-line fix (local import alongside write_runtime_status) + regression tests that drive the real method and assert the stores materialize, verified red without the import and green with it. Surfaced during the profile-routing sweep by @CocaKova's PR #61689, which included the same fix as part of a larger feature. --- gateway/run.py | 1 + .../gateway/test_multiplex_pairing_stores.py | 85 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/gateway/test_multiplex_pairing_stores.py diff --git a/gateway/run.py b/gateway/run.py index 84a64db3f9db..f05d89540b5e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8661,6 +8661,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # Record served profiles in runtime status for `hermes status`. try: from gateway.status import write_runtime_status + from gateway.pairing import PairingStore served = [active] + sorted(self._profile_adapters.keys()) # Per-profile PairingStores so authz_mixin can route pairing # checks to the right whitelist. The active profile gets a store diff --git a/tests/gateway/test_multiplex_pairing_stores.py b/tests/gateway/test_multiplex_pairing_stores.py new file mode 100644 index 000000000000..356a9230076d --- /dev/null +++ b/tests/gateway/test_multiplex_pairing_stores.py @@ -0,0 +1,85 @@ +"""Regression: per-profile PairingStore creation in _start_secondary_profile_adapters. + +``gateway/run.py`` referenced ``PairingStore`` at method scope in +``_start_secondary_profile_adapters`` while the class's only import was +method-local inside ``__init__`` — a ``NameError`` at runtime, silently +swallowed by the enclosing ``try/except``, so multiplexing gateways never +created per-profile pairing stores and authz pairing checks for secondary +profiles fell through to the global whitelist. + +These tests drive the REAL method (bound onto a bare runner) with the +profile-enumeration and adapter-startup collaborators stubbed, and assert +the per-profile stores actually materialize. +""" + +import asyncio +from unittest.mock import MagicMock, patch + +from gateway.run import GatewayRunner + + +def _bare_runner(multiplex: bool = True): + runner = object.__new__(GatewayRunner) + runner.config = MagicMock(multiplex_profiles=multiplex) + runner.adapters = {} + runner._profile_adapters = {} + runner.pairing_stores = {} + return runner + + +def test_secondary_profile_pairing_stores_created(tmp_path, monkeypatch): + """The served-profiles loop must create a PairingStore per profile. + + Pre-fix this silently did nothing: the ``PairingStore(profile=name)`` + reference raised NameError inside the swallowed try/except. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + + runner = _bare_runner() + + async def _no_secondary(profile_name, profile_home, claimed): + return 0 + + runner._start_one_profile_adapters = _no_secondary + runner._adapter_credential_fingerprint = lambda adapter: None + + with patch("hermes_cli.profiles.profiles_to_serve", return_value=[ + ("coder", tmp_path / ".hermes" / "profiles" / "coder"), + ]), patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): + runner._profile_adapters["coder"] = {} + asyncio.run(runner._start_secondary_profile_adapters()) + + # Both the active profile and the served secondary get a store. + assert "default" in runner.pairing_stores, ( + "active profile PairingStore missing — the NameError swallow is back" + ) + assert "coder" in runner.pairing_stores, ( + "secondary profile PairingStore missing — the NameError swallow is back" + ) + + +def test_pairing_store_scoped_to_profile_dir(tmp_path, monkeypatch): + """The created store must live under the profile's pairing directory.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + + runner = _bare_runner() + + async def _no_secondary(profile_name, profile_home, claimed): + return 0 + + runner._start_one_profile_adapters = _no_secondary + runner._adapter_credential_fingerprint = lambda adapter: None + + with patch("hermes_cli.profiles.profiles_to_serve", return_value=[ + ("ops", tmp_path / ".hermes" / "profiles" / "ops"), + ]), patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): + runner._profile_adapters["ops"] = {} + asyncio.run(runner._start_secondary_profile_adapters()) + + store = runner.pairing_stores["ops"] + assert store.profile == "ops" + assert "profiles/ops/pairing" in str(store._dir).replace("\\", "/"), ( + f"store not profile-scoped: {store._dir}" + )