fix(gateway): per-profile pairing whitelist isolation in multiplex mode (#53045 salvage) (#59330)

* fix(gateway): per-profile pairing whitelist isolation for multiplex gateways

Pairing approvals are stored per profile (profiles/<name>/pairing/) and
authz routes pairing checks through the serving profile's store, so one
profile's approved users no longer authorize against every other
profile's whitelist in multiplex mode.

The global store remains for the hermes pairing CLI and single-profile
gateways; unregistered/unstamped sources fall back to it, preserving
existing behavior.

Salvaged from PR #53045 (pairing half). The SOUL.md half was dropped:
the agent turn already runs inside _profile_runtime_scope on main, so
load_soul_md() resolves per-profile without changes.

Original work by @soddy022.

* ci: redispatch after arm64 docker dashboard-slot flake (unrelated to this PR)

---------

Co-authored-by: soddy022 <290613374+soddy022@users.noreply.github.com>
This commit is contained in:
Teknium 2026-07-07 01:25:52 -07:00 committed by GitHub
parent 088b989442
commit 249c69b958
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 166 additions and 8 deletions

View file

@ -720,3 +720,110 @@ class TestUnreadablePairingFile:
# The warning must fire — otherwise this is the silent-failure bug.
assert any(rec.levelno == logging.WARNING for rec in caplog.records), \
"PermissionError on approved.json must produce a WARNING log line"
# Profile-scoped storage (multiplexing gateway isolation)
# ---------------------------------------------------------------------------
class TestProfileScopedStorage:
"""PairingStore(profile="<name>") should isolate per-profile whitelists
under <HERMES_HOME>/profiles/<name>/pairing/ so a multiplexing gateway
can keep each profile's allowlist separate.
"""
def test_default_store_uses_global_dir(self, tmp_path, monkeypatch):
"""PairingStore() (no profile) keeps the legacy global path so the
``hermes pairing`` CLI continues to work without a profile context."""
from hermes_constants import get_hermes_home
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
# Re-import PAIRING_DIR (it's a module-level constant resolved at
# import time) so the test exercises the right path. We patch it
# rather than re-importing so the assertion is unambiguous.
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
store = PairingStore()
assert store.profile is None
assert store._dir == tmp_path
assert store._approved_path("weixin") == tmp_path / "weixin-approved.json"
def test_profile_store_uses_profiles_subdir(self, tmp_path, monkeypatch):
"""PairingStore(profile="yangyang") puts files under
<HERMES_HOME>/profiles/yangyang/pairing/."""
from hermes_constants import get_hermes_home
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
store = PairingStore(profile="yangyang")
assert store.profile == "yangyang"
expected = tmp_path / "profiles" / "yangyang" / "pairing"
assert store._dir == expected
assert store._approved_path("weixin") == expected / "weixin-approved.json"
# Auto-creates the directory
assert expected.is_dir()
def test_profile_approval_does_not_leak_to_global(self, tmp_path, monkeypatch):
"""Approving in a profile-scoped store must not appear in the global
store and vice versa. This is the whole point of the fix."""
from hermes_constants import get_hermes_home
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
global_store = PairingStore()
profile_store = PairingStore(profile="yangyang")
# Approve in the profile store
profile_store._approve_user("weixin", "yangyang_user", "杨洋")
# And in the global store, a different user
global_store._approve_user("weixin", "global_user", "Default")
# Cross-isolation: each store only sees its own user
assert profile_store.is_approved("weixin", "yangyang_user") is True
assert profile_store.is_approved("weixin", "global_user") is False
assert global_store.is_approved("weixin", "global_user") is True
assert global_store.is_approved("weixin", "yangyang_user") is False
def test_profile_uses_distinct_rate_limit_file(self, tmp_path, monkeypatch):
"""Rate-limit state is per-profile, not shared globally — otherwise
one profile's flood would lock out the other profile's users."""
from hermes_constants import get_hermes_home
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path)
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
global_store = PairingStore()
profile_store = PairingStore(profile="yangyang")
assert global_store._rate_limit_path() == tmp_path / "_rate_limits.json"
assert profile_store._rate_limit_path() == (
tmp_path / "profiles" / "yangyang" / "pairing" / "_rate_limits.json"
)
def test_pairing_store_for_helper_routes_by_profile(self, tmp_path, monkeypatch):
"""_pairing_store_for(source) on a gateway-like object picks the
per-profile store when source.profile is set, and falls back to
the global store when it isn't (defensive — single-profile
gateways, or any code path that hasn't stamped source.profile)."""
from gateway.session import SessionSource
from gateway.config import Platform
class FakeGateway:
def __init__(self):
self.pairing_store = object() # sentinel
self.pairing_stores = {
"default": "default-store",
"yangyang": "yangyang-store",
}
# Method under test — copy of the real helper so this test
# is self-contained even if the real one moves.
def _pairing_store_for(self, source):
per_profile = getattr(self, "pairing_stores", None) or {}
profile = getattr(source, "profile", None)
if profile and profile in per_profile:
return per_profile[profile]
return getattr(self, "pairing_store", None)
g = FakeGateway()
# source with profile="yangyang" → per-profile store
s_yy = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="yangyang")
assert g._pairing_store_for(s_yy) == "yangyang-store"
# source with no profile → fallback to global
s_none = SessionSource(platform=Platform.WEIXIN, chat_id="c")
assert g._pairing_store_for(s_none) is g.pairing_store
# source with an unknown profile → fallback (defensive)
s_unknown = SessionSource(platform=Platform.WEIXIN, chat_id="c", profile="ghost")
assert g._pairing_store_for(s_unknown) is g.pairing_store