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.
222 lines
10 KiB
Python
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")
|