Files
hermes-agent/tests/gateway/test_multiplex_interactive_auth.py
kshitijk4poor e13b5e71ef fix(gateway): bind the secondary callback scope without on-loop secret hydration
The adapter auth check is synchronous and runs on the adapter's event loop:
once per inline-button tap, and once per keystroke for Telegram inline
queries. Entering `_profile_runtime_scope` with its default
`hydrate_secrets=True` there calls `hydrate_profile_secret_sources`, which
takes the process-global secret-source lock and may resolve external secret
backends. That lock contention is the heartbeat-starvation class #99519
moved off the loop.

Startup and the per-profile message path already hydrate this profile's
sources off-loop, so the callback now binds the scope with
`hydrate_secrets=False` (the reconnect-retry precedent in the same module):
`build_profile_secret_scope` still re-reads the profile's `.env` per call,
so allowlist edits keep reaching the next tap.

The regression test also seeds the default profile's allowlist (file and
live os.environ) with a different user and asserts the secondary's bot
refuses them; on main that user was admitted on the secondary's buttons.
2026-09-26 06:41:34 +05:30

222 lines
10 KiB
Python

"""Multiplex interactive-auth regressions (#86296, #92840, #72657, #87240 egress).
Real ``GatewayRunner`` methods on an ``object.__new__`` runner, real
``PairingStore`` files under a temp HERMES_HOME, multiplex active.
"""
from pathlib import Path
from types import SimpleNamespace
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.pairing import PairingStore
from gateway.profile_routing import ProfileRoute
@pytest.fixture
def mux_home(tmp_path, monkeypatch):
from agent import secret_scope
home = tmp_path / "hh"
(home / "profiles" / "secondary").mkdir(parents=True)
(home / ".env").write_text("", encoding="utf-8")
(home / "profiles" / "secondary" / ".env").write_text("", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
for key in (
"TELEGRAM_ALLOWED_USERS",
"TELEGRAM_ALLOW_BOTS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
"SLACK_ALLOW_ALL_USERS",
"SLACK_ALLOWED_USERS",
):
monkeypatch.delenv(key, raising=False)
prev = secret_scope.is_multiplex_active()
secret_scope.set_multiplex_active(True)
yield home
secret_scope.set_multiplex_active(prev)
def _runner(home):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner.config.profile_routes = [
ProfileRoute(name="r", platform="telegram", chat_id="-100555", profile="secondary")
]
runner.config.platforms = {Platform.TELEGRAM: PlatformConfig(enabled=True, extra={})}
runner.pairing_store = PairingStore(profile="default")
runner.pairing_stores = {
"default": runner.pairing_store,
"secondary": PairingStore(profile="secondary"),
}
runner._primary_profile_name = "default"
runner._profile_adapters = {"secondary": {}}
return runner
def _telegram(runner):
from plugins.platforms.telegram.adapter import TelegramAdapter
tg = object.__new__(TelegramAdapter)
tg.config = PlatformConfig(enabled=True, extra={})
tg._authorization_check = None
tg._message_handler = runner._primary_message_handler() # closure, no __self__
runner.adapters = {Platform.TELEGRAM: tg}
tg.set_authorization_check(runner._make_adapter_auth_check(Platform.TELEGRAM))
return tg
def test_routed_primary_callback_uses_routed_pairing_store_and_transport_allowlist(mux_home):
"""#86296: shared primary bot + profile_routes → the inline-button caller
is authorized by the ROUTED profile's pairing store, while env allowlists
resolve under the transport (launch) home, exactly like inbound messages."""
runner = _runner(mux_home)
store = runner.pairing_stores["secondary"]
store._save_json(store._approved_path("telegram"), {"777": {}})
(mux_home / ".env").write_text("TELEGRAM_ALLOWED_USERS=999\n", encoding="utf-8")
tg = _telegram(runner)
# Paired only in the routed profile → allowed in the routed chat only.
assert tg._is_callback_user_authorized("777", chat_id="-100555", chat_type="supergroup") is True
assert tg._is_callback_user_authorized("777", chat_id="-100999", chat_type="supergroup") is False
# Transport-home allowlist honored in the routed chat (not the routed profile's empty scope).
assert tg._is_callback_user_authorized("999", chat_id="-100555", chat_type="supergroup") is True
assert tg._is_callback_user_authorized("888", chat_id="-100555", chat_type="supergroup") is False
def test_bot_sender_reaches_allow_bots_policy_through_callback(mux_home):
"""#92840: the early prefilter must carry ``is_bot`` so TELEGRAM_ALLOW_BOTS
admits bot-authored messages under the multiplex closure handler."""
from gateway.run import _profile_runtime_scope
runner = _runner(mux_home)
(mux_home / ".env").write_text("TELEGRAM_ALLOWED_USERS=999\nTELEGRAM_ALLOW_BOTS=all\n", encoding="utf-8")
tg = _telegram(runner)
def msg(uid, is_bot):
return SimpleNamespace(
from_user=SimpleNamespace(id=uid, is_bot=is_bot, username="x", full_name="X"),
chat=SimpleNamespace(id=-100777, type="supergroup", is_forum=False),
sender_chat=None,
message_thread_id=None,
is_topic_message=False,
)
with _profile_runtime_scope(mux_home):
assert tg._is_user_authorized_from_message(msg(4242, True)) is True
assert tg._is_user_authorized_from_message(msg(4343, False)) is False
def test_secondary_owned_callback_reads_own_profile_allowlist(mux_home, monkeypatch):
"""#120639: a secondary profile's own bot authorizes inline-button callers
against the OWNING profile's allowlist — the same ``.env`` scope the
cold-path message handler reads — even though the callback fires outside
any profile runtime scope, straight off the adapter's event loop. The
default profile's allowlist never leaks in, and the sync check never
hydrates external secret sources on that loop (#99519 class)."""
import hermes_cli.env_loader as env_loader
hydrated = []
monkeypatch.setattr(env_loader, "hydrate_profile_secret_sources", hydrated.append)
runner = _runner(mux_home)
(mux_home / ".env").write_text("TELEGRAM_ALLOWED_USERS=999\n", encoding="utf-8")
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "999") # the default profile's live os.environ
(mux_home / "profiles" / "secondary" / ".env").write_text("TELEGRAM_ALLOWED_USERS=555\n", encoding="utf-8")
tg = _telegram(runner)
tg._hermes_profile_name = "secondary"
runner._profile_adapters = {"secondary": {Platform.TELEGRAM: tg}}
tg.set_authorization_check(
runner._make_adapter_auth_check(Platform.TELEGRAM, profile_name="secondary")
)
# No ambient profile scope: the adapter event loop invokes the callback bare.
assert tg._is_callback_user_authorized("555", chat_id="111", chat_type="private") is True
assert tg._is_callback_user_authorized("999", chat_id="111", chat_type="private") is False
assert hydrated == []
def test_secondary_callback_allowlist_follows_env_edits(mux_home):
"""#120639 review: the secondary's callback check re-enters the owning
profile's scope per call, so an operator's runtime edit to
``profiles/<name>/.env`` reaches the NEXT button tap — the same freshness
the message path gives (``_make_profile_message_handler`` re-reads the
``.env`` per message), no allow/deny split until the adapter reconnects."""
runner = _runner(mux_home)
env_path = mux_home / "profiles" / "secondary" / ".env"
env_path.write_text("TELEGRAM_ALLOWED_USERS=555\n", encoding="utf-8")
tg = _telegram(runner)
tg._hermes_profile_name = "secondary"
runner._profile_adapters = {"secondary": {Platform.TELEGRAM: tg}}
tg.set_authorization_check(
runner._make_adapter_auth_check(Platform.TELEGRAM, profile_name="secondary")
)
assert tg._is_callback_user_authorized("777", chat_id="111", chat_type="private") is False
env_path.write_text("TELEGRAM_ALLOWED_USERS=555,777\n", encoding="utf-8") # operator adds a user at runtime
assert tg._is_callback_user_authorized("777", chat_id="111", chat_type="private") is True
def test_slack_interactive_auth_prefers_wired_profile_check(mux_home, monkeypatch):
"""#72657: a multiplexed Slack adapter's button gate resolves through the
wired ``_make_adapter_auth_check`` for its own profile; the DEFAULT
profile's process-env allow-all never leaks in — not through the
injected path, and not through the env-only fallback either."""
from gateway.run import _profile_runtime_scope
from plugins.platforms.slack.adapter import SlackAdapter
runner = _runner(mux_home)
runner.adapters = {}
sec_home = mux_home / "profiles" / "secondary"
(sec_home / ".env").write_text("SLACK_ALLOWED_USERS=U_SEC\n", encoding="utf-8")
monkeypatch.setenv("SLACK_ALLOW_ALL_USERS", "true")
def slack(with_check):
sl = object.__new__(SlackAdapter)
sl.config = PlatformConfig(enabled=True, extra={})
sl._authorization_check = None
sl._message_handler = runner._make_profile_message_handler("secondary")
if with_check:
runner._profile_adapters = {"secondary": {Platform.SLACK: sl}}
sl.set_authorization_check(
runner._make_adapter_auth_check(Platform.SLACK, profile_name="secondary")
)
return sl
with _profile_runtime_scope(sec_home):
wired = slack(True)
assert wired._is_interactive_user_authorized("U_SEC", channel_id="C1") is True
assert wired._is_interactive_user_authorized("U_X", channel_id="C1") is False
assert slack(False)._is_interactive_user_authorized("U_X", channel_id="C1") is False
def test_authorization_adapter_ignores_per_turn_active_profile(mux_home):
"""#87240 egress half: inside a secondary profile's runtime scope the
default bot must not be handed to that profile (fail-closed None); the
launch profile still resolves ``self.adapters``. The secondary's own bot
is down (reconnect pending), so it is not a route-only satellite."""
from gateway.run import _profile_runtime_scope
runner = _runner(mux_home)
default_bot = object()
runner.adapters = {Platform.TELEGRAM: default_bot}
runner._profile_failed_platforms = {"secondary": {Platform.TELEGRAM: object()}}
with _profile_runtime_scope(mux_home / "profiles" / "secondary"):
assert runner._authorization_adapter(Platform.TELEGRAM, profile="secondary") is None
assert runner._authorization_adapter(Platform.TELEGRAM, profile="default") is default_bot
def test_channel_directory_path_follows_current_home(mux_home):
"""#87240: the directory file resolves against the CURRENT profile home,
not the home that happened to import the module."""
import gateway.channel_directory as cd
from gateway.run import _profile_runtime_scope
assert cd.DIRECTORY_PATH is None
with _profile_runtime_scope(mux_home / "profiles" / "secondary"):
assert cd._directory_path() == Path(mux_home / "profiles" / "secondary" / "channel_directory.json")
assert cd._directory_path() == Path(mux_home / "channel_directory.json")