From 86152df0ae6cbbefabdd9cdeb09e0fa99ff23b9f Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:44:43 -0700 Subject: [PATCH 1/2] fix(gateway): scope pairing platform discovery to the profile dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-profile pairing isolation added self._dir and scoped every per-file path helper (_pending_path, _approved_path) to it, but _all_platforms still enumerated the module-global PAIRING_DIR. For a profile-scoped PairingStore, list_approved/list_pending/clear_pending therefore operated on the GLOBAL platform set while loading each platform's file from the PROFILE dir — so list_approved() returned [] for a user that is_approved() confirmed as approved, a silent divergence between the authz surface and the list/inspect/clear surface. Route discovery through self._dir. Byte-identical for the global store (self._dir == PAIRING_DIR when no profile is set); only the buggy profile-scoped case changes. self._dir is guaranteed to exist (__init__ mkdirs it). --- gateway/pairing.py | 2 +- tests/gateway/test_pairing.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/gateway/pairing.py b/gateway/pairing.py index bf6212f97f3f9..e0be62e682e2e 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -653,7 +653,7 @@ def _cleanup_expired(self, platform: str) -> None: def _all_platforms(self, suffix: str) -> list: """List all platforms that have data files of a given suffix.""" platforms = [] - for f in PAIRING_DIR.iterdir(): + for f in self._dir.iterdir(): if f.name.endswith(f"-{suffix}.json"): platform = f.name.replace(f"-{suffix}.json", "") if not platform.startswith("_"): diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index ef1037692e6b0..c5a2ae3c49e1a 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -69,6 +69,34 @@ def test_active_entries_win_when_merging_split_dirs(self, tmp_path): assert migrated["ou_other"]["user_name"] == "Other" +class TestProfileScopedDiscovery: + def test_list_approved_scopes_platform_discovery_to_profile_dir(self, tmp_path): + # A profile-scoped store must enumerate platforms from its own + # per-profile directory (self._dir), not the module-global PAIRING_DIR. + # Regression: _all_platforms iterated PAIRING_DIR while every per-file + # path helper routed through self._dir, so a profile store confirmed a + # user via is_approved() (reads self._dir) yet returned [] from + # list_approved() (scanned the empty global dir). + home = tmp_path / "home" + global_dir = tmp_path / "global-pairing" + global_dir.mkdir(parents=True) + + with patch("gateway.pairing.PAIRING_DIR", global_dir), patch( + "gateway.pairing.get_hermes_home", return_value=home + ): + store = PairingStore(profile="alice") + # Store lives under the profile dir, provably distinct from PAIRING_DIR. + assert store._dir != global_dir + with store._lock: + store._approve_user("telegram", "tg-456", "Bob") + + assert store.is_approved("telegram", "tg-456") is True + approved = store.list_approved() + + assert [r["user_id"] for r in approved] == ["tg-456"] + assert approved[0]["platform"] == "telegram" + + # --------------------------------------------------------------------------- # _secure_write # --------------------------------------------------------------------------- From 2708fa8e6b57aeaa4cba4254e33cea333f7f6fd8 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:43:09 -0700 Subject: [PATCH 2/2] test(gateway): patch hermes_constants.get_hermes_home so profile store scopes to the mocked home --- tests/gateway/test_pairing.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index c5a2ae3c49e1a..03a3a9a65b63e 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -81,11 +81,19 @@ def test_list_approved_scopes_platform_discovery_to_profile_dir(self, tmp_path): global_dir = tmp_path / "global-pairing" global_dir.mkdir(parents=True) + # PairingStore.__init__ resolves the profile dir via a *function-local* + # ``from hermes_constants import get_hermes_home``, so the patch must + # target ``hermes_constants.get_hermes_home`` (the source module) — not + # ``gateway.pairing.get_hermes_home`` (the module-global binding, which + # the local re-import bypasses). Patching the wrong target would leave + # ``self._dir`` rooted at the real HERMES_HOME. with patch("gateway.pairing.PAIRING_DIR", global_dir), patch( - "gateway.pairing.get_hermes_home", return_value=home + "hermes_constants.get_hermes_home", return_value=home ): store = PairingStore(profile="alice") - # Store lives under the profile dir, provably distinct from PAIRING_DIR. + # Store lives under the mocked home's profile dir — provably scoped + # there, and distinct from the module-global PAIRING_DIR. + assert store._dir == home / "profiles" / "alice" / "pairing" assert store._dir != global_dir with store._lock: store._approve_user("telegram", "tg-456", "Bob")