"""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//.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")