Files
hermes-agent/tests/gateway/test_multiplex_interactive_auth.py
2026-10-08 17:22:54 +02:00

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)."""
from hermes_cli import 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")