test(gateway): invariant tests for per-profile setting precedence and its consumers

Real loader + real adapter constructors under _profile_runtime_scope: a
secondary reads its own YAML lists/flags and never the launch env on a miss;
explicit env beats YAML for the owning profile; the central allow_bots gate
agrees with the adapter; Matrix YAML lists gate intake and approval; Yuanbao
home channel is live and reloadable; the WhatsApp bridge env carries the
secondary's policy. All eight cases red on origin/main.
This commit is contained in:
teknium1
2026-09-13 13:53:50 -07:00
committed by Teknium
parent a6383dfeb7
commit bbf38b6901
@@ -0,0 +1,191 @@
"""Per-profile adapter settings resolve explicit scoped env → the profile's own YAML → default.
Invariants from the #108440 post-merge review: a multiplexed secondary constructed under the real
``_profile_runtime_scope`` reads its own YAML lists/flags (Matrix authz lists, Telegram proxy,
Discord mentions), never inherits the launch process's env on a scoped miss, and the central
``allow_bots`` gate agrees with the adapter; an explicit env value still beats YAML for the owning
profile (single-profile contract). Real loader, real constructors; only transport is substituted.
"""
from __future__ import annotations
import asyncio
import contextlib
import types
from unittest.mock import AsyncMock, patch
import pytest
from agent.secret_scope import set_multiplex_active
from gateway.config import Platform, load_gateway_config
from hermes_cli.plugins import discover_plugins
@pytest.fixture(autouse=True)
def _plugins():
discover_plugins()
class _Mentions:
"""``discord.AllowedMentions`` stand-in exposing the four flags (the suite stubs ``discord``)."""
def __init__(self, *, everyone, roles, users, replied_user):
self.everyone, self.roles, self.users, self.replied_user = everyone, roles, users, replied_user
def _allowed_mentions(extra):
from plugins.platforms.discord import adapter as discord_adapter
with patch.object(discord_adapter, "DISCORD_AVAILABLE", True), \
patch.object(discord_adapter, "discord", types.SimpleNamespace(AllowedMentions=_Mentions), create=True):
return discord_adapter._build_allowed_mentions(extra)
@pytest.fixture
def homes(tmp_path, monkeypatch):
"""(launch_home, secondary_home); HERMES_HOME points at the launch home, multiplex off on exit."""
launch = tmp_path / "launch"
secondary = tmp_path / "launch" / "profiles" / "b2"
secondary.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(launch))
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
yield launch, secondary
set_multiplex_active(False)
@contextlib.contextmanager
def _secondary_scope(home):
from gateway.run import _profile_runtime_scope
set_multiplex_active(True)
try:
with _profile_runtime_scope(home, prepared_secret_scope={}):
yield
finally:
set_multiplex_active(False)
def test_secondary_reads_own_yaml_and_never_the_launch_env(homes, monkeypatch):
launch, secondary = homes
(launch / "config.yaml").write_text(
"matrix:\n process_notices: true\n session_scope: room\n"
"discord:\n reactions: false\n allow_mentions:\n everyone: true\n"
"slack:\n reactions: false\n ignored_channels: [C_LAUNCH]\n"
"telegram:\n reactions: true\n")
load_gateway_config() # launch profile bridges its YAML into os.environ (single-profile contract)
(secondary / "config.yaml").write_text(
"matrix:\n enabled: true\n user_id: '@bot:example.org'\n"
" allowed_users: ['@owner:example.org']\n ignore_user_patterns: ['^@ignored:']\n"
"discord:\n enabled: true\nslack:\n enabled: true\n"
"telegram:\n enabled: true\n proxy_url: http://127.0.0.1:18080\n")
from plugins.platforms.discord.adapter import DiscordAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
from plugins.platforms.slack.adapter import SlackAdapter
from plugins.platforms.telegram import adapter as tg
with _secondary_scope(secondary):
cfg = load_gateway_config()
m = MatrixAdapter(cfg.platforms[Platform.MATRIX])
d = DiscordAdapter(cfg.platforms[Platform.DISCORD])
s = SlackAdapter(cfg.platforms[Platform.SLACK])
t = tg.TelegramAdapter(cfg.platforms[Platform.TELEGRAM])
# Omitted keys resolve to the DEFAULT, not the launch profile's bridged env.
assert (m._process_notices, m._matrix_session_scope) == (False, "auto")
assert d._reactions_enabled() and s._reactions_enabled() and s._slack_ignored_channels() == set()
assert not t._reactions_enabled()
assert _allowed_mentions(d.config.extra).everyone is False
# The seeded YAML lists reach the Matrix authz consumers.
assert m._allowed_user_ids == {"@owner:example.org"} and len(m._ignored_user_patterns) == 1
# The secondary's YAML proxy reaches request construction without an env bridge.
monkeypatch.setenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "true")
built: list = []
with patch.object(tg, "HTTPXRequest", lambda **kw: built.append(kw) or types.SimpleNamespace()), \
patch.object(t, "_instrument_polling_request", side_effect=lambda r: r):
asyncio.run(t._build_ptb_requests())
assert [kw.get("proxy") for kw in built] == ["http://127.0.0.1:18080"] * 2
def test_explicit_env_beats_yaml_for_the_owning_profile(homes, monkeypatch):
"""Single-profile / owning-profile contract: DISCORD_ALLOW_MENTION_EVERYONE=false beats
``everyone: true`` and TELEGRAM_REACTIONS=true beats the stock ``reactions: false`` (#109032)."""
launch, _ = homes
(launch / "config.yaml").write_text(
"discord:\n allow_mentions:\n everyone: true\ntelegram:\n reactions: false\n")
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "false")
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
from plugins.platforms.telegram.adapter import TelegramAdapter
cfg = load_gateway_config()
assert _allowed_mentions(cfg.platforms[Platform.DISCORD].extra).everyone is False
assert TelegramAdapter(cfg.platforms[Platform.TELEGRAM])._reactions_enabled() is True
def test_central_allow_bots_gate_honours_a_secondary_yaml_policy(homes):
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from plugins.platforms.discord.adapter import DiscordAdapter
from plugins.platforms.slack.adapter import SlackAdapter
_, secondary = homes
(secondary / "config.yaml").write_text(
"discord:\n enabled: true\n allow_bots: all\nslack:\n enabled: true\n allow_bots: all\n")
with _secondary_scope(secondary):
cfg = load_gateway_config()
d, s = DiscordAdapter(cfg.platforms[Platform.DISCORD]), SlackAdapter(cfg.platforms[Platform.SLACK])
runner = object.__new__(GatewayRunner)
runner.config, runner._primary_profile_name, runner.adapters = cfg, "default", {}
runner._profile_adapters = {"b2": {Platform.DISCORD: d, Platform.SLACK: s}}
for platform, user_id in ((Platform.DISCORD, "123"), (Platform.SLACK, None)):
src = SessionSource(platform=platform, chat_id="C_TEST", chat_type="group", user_id=user_id,
is_bot=True, profile="b2")
assert runner._is_user_authorized(src), platform
def test_matrix_yaml_lists_gate_intake_and_approval(homes):
from plugins.platforms.matrix.adapter import MatrixAdapter
_, secondary = homes
(secondary / "config.yaml").write_text(
"matrix:\n enabled: true\n user_id: '@bot:example.org'\n"
" allowed_users: ['@owner:example.org']\n ignore_user_patterns: ['^@ignored:']\n")
with _secondary_scope(secondary):
a = MatrixAdapter(load_gateway_config().platforms[Platform.MATRIX])
a._user_id = "@bot:example.org"
a._is_allowed_matrix_room_event = AsyncMock(return_value=True)
a._handle_text_message = AsyncMock()
a.send = AsyncMock()
event = types.SimpleNamespace(room_id="!r:example.org", sender="@ignored:example.org", event_id="$1",
content={"msgtype": "m.text", "body": "hello"})
asyncio.run(a._on_room_message(event))
prompt = types.SimpleNamespace(requester_user_id="@owner:example.org")
owner_ok = asyncio.run(a._validate_matrix_prompt_reactor("!r:example.org", "$a", "@owner:example.org", prompt, "approval"))
other_ok = asyncio.run(a._validate_matrix_prompt_reactor("!r:example.org", "$a", "@other:example.org", prompt, "approval"))
assert a._handle_text_message.await_count == 0 and owner_ok and not other_ok
def test_yuanbao_secondary_home_channel_is_live_and_reloadable(homes):
"""Auto-sethome from a secondary lands in ``platforms.yuanbao.home_channel`` of ITS config (read back
by ``load_gateway_config``) and on the live PlatformConfig; the process env stays untouched."""
import os
from gateway.platforms.yuanbao import AutoSetHomeMiddleware
_, secondary = homes
(secondary / "config.yaml").write_text(
"platforms:\n yuanbao:\n enabled: true\n extra:\n app_id: a\n app_secret: b\n")
adapter = types.SimpleNamespace(name="yuanbao-b2")
ctx = types.SimpleNamespace(chat_id="dm:tenant-b2", chat_name="b2")
with _secondary_scope(secondary):
adapter.config = load_gateway_config().platforms[Platform.YUANBAO]
AutoSetHomeMiddleware._persist_home(adapter, ctx)
reloaded = load_gateway_config().get_home_channel(Platform.YUANBAO)
assert adapter.config.home_channel.chat_id == "dm:tenant-b2"
assert reloaded is not None and reloaded.chat_id == "dm:tenant-b2"
assert "YUANBAO_HOME_CHANNEL" not in os.environ
def test_whatsapp_bridge_env_carries_the_secondary_effective_policy(homes, monkeypatch):
"""bridge.js gates DMs before Python: it must receive the adapter's resolved dm_policy/allow_from, not the
launch process's WHATSAPP_* values."""
from plugins.platforms.whatsapp.adapter import WhatsAppAdapter
_, secondary = homes
monkeypatch.setenv("WHATSAPP_DM_POLICY", "allowlist")
monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550001111")
(secondary / "config.yaml").write_text("whatsapp:\n enabled: true\n dm_policy: pairing\n")
with _secondary_scope(secondary):
a = WhatsAppAdapter(load_gateway_config().platforms[Platform.WHATSAPP])
env = a._bridge_env()
assert a._dm_policy == "pairing" == env["WHATSAPP_DM_POLICY"]
assert "WHATSAPP_ALLOWED_USERS" not in env