diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 18a2c226e2..312fc853f8 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -69,6 +69,26 @@ def _auth_env(name: str, default: str = "") -> str: return _platform_gate_env(name, default) +def _platform_declares_allowed_users_env(platform) -> bool: + """Whether a plugin platform's registry entry declares ``allowed_users_env``. + + Such platforms (Buzz, DingTalk, …) document ``PlatformConfig.extra + .allowed_users`` as the config-file spelling of that env allowlist, so + the live adapter's extra is a valid authorization source when the env + var is absent (#98738 / #82871). Built-in platforms and unknown entries + return False. + """ + if platform is None: + return False + try: + from gateway.platform_registry import platform_registry + + entry = platform_registry.get(platform.value) + return bool(entry and entry.allowed_users_env) + except Exception: + return False + + def _coerce_allow_set(raw) -> set[str]: """Parse allowlist values from config or env var into a set of strings. @@ -692,8 +712,26 @@ class GatewayAuthorizationMixin: adapter_allow = extra.get("group_allow_from") else: adapter_allow = extra.get("allow_from") + if not adapter_allow and _platform_declares_allowed_users_env(source.platform): + # Plugin platforms whose registry entry declares + # ``allowed_users_env`` (e.g. Buzz) carry the same + # operator-configured allowlist in + # ``PlatformConfig.extra.allowed_users``. Under multiplex + # the YAML→env bridge is first-writer-wins, so only the + # default profile's list ever reaches the env var read + # above; consult the live (profile-routed) adapter's own + # config so a secondary profile's allowlist authorizes its + # users (#98738 / #82871). An absent/empty entry changes + # nothing here — the default-deny below still applies. + adapter_allow = extra.get("allowed_users") if adapter_allow: allowed = _coerce_allow_set(adapter_allow) + normalize = getattr(adapter, "normalize_user_id", None) + if callable(normalize): + # Ids and allowlist entries may use different + # spellings of the same principal (e.g. Buzz hex + # pubkeys vs npubs) — normalize the entries. + allowed = {normalize(entry) or entry for entry in allowed} if user_id in allowed or "*" in allowed: return True # No allowlists configured -- check global allow-all flag diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index dcb1ac3d8d..93fd532eaa 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -73,6 +73,41 @@ def _get_scoped_secret(name, default=None): return val if val is not None else default +def _profile_scoped() -> bool: + """True when running inside a multiplexed secondary profile's scope. + + Secondary-profile adapters are constructed, connected, and reloaded + inside ``_profile_runtime_scope`` (secret scope installed + multiplex + active) — the same discriminator as the Discord adapter's + ``_profile_scoped_config_load`` (#72348). The DEFAULT profile under + multiplexing runs unscoped: ``os.environ`` holds its own bridge output + there and keeps its legacy precedence. + """ + try: + from agent.secret_scope import current_secret_scope, is_multiplex_active + + return bool(is_multiplex_active() and current_secret_scope() is not None) + except Exception: + return False + + +def _scoped_platform_setting(env_name, extra, key): + """Raw read of a non-secret Buzz setting, multiplex-profile-correct. + + Inside a secondary profile scope ``os.environ`` holds the DEFAULT + profile's YAML-to-env bridge output (#98738), so the profile's + ``PlatformConfig.extra`` is authoritative and env is not consulted: a + missing key yields ``None`` and callers fail closed to their default + instead of silently borrowing the default profile's relay, channels, or + allowlist. Everywhere else — single-profile gateways, the default + profile under multiplexing — the legacy ``os.getenv`` read is returned + unchanged, so env-over-config precedence is preserved. + """ + if _profile_scoped(): + return (extra or {}).get(key) + return os.getenv(env_name) + + logger = logging.getLogger(__name__) from gateway.platforms.base import ( @@ -253,7 +288,8 @@ def _resolve_private_key(extra: Optional[dict] = None) -> str: key = _get_scoped_secret("BUZZ_PRIVATE_KEY", "").strip() if key: return key - configured = os.getenv("BUZZ_CREDENTIALS_FILE", "").strip() or (extra or {}).get("credentials_file", "") + _creds_raw = _scoped_platform_setting("BUZZ_CREDENTIALS_FILE", extra, "credentials_file") + configured = str(_creds_raw or "").strip() or (extra or {}).get("credentials_file", "") if configured: candidates = [Path(configured).expanduser()] else: @@ -361,22 +397,30 @@ class BuzzAdapter(BasePlatformAdapter): extra = getattr(config, "extra", {}) or {} self._extra = extra - # Connection settings (env vars override config.yaml) - self.relay_url = (os.getenv("BUZZ_RELAY_URL") or extra.get("relay_url", "")).strip() + # Connection settings (env vars override config.yaml; under a + # secondary multiplex profile scope the profile's extra wins and + # env — the default profile's bridge output — is not consulted) + _relay_raw = _scoped_platform_setting("BUZZ_RELAY_URL", extra, "relay_url") + self.relay_url = (_relay_raw or extra.get("relay_url", "")).strip() + _cli_raw = _scoped_platform_setting("BUZZ_CLI_PATH", extra, "cli_path") self.cli_path = _resolve_cli_path( - os.getenv("BUZZ_CLI_PATH", "").strip() or str(extra.get("cli_path", "") or "") + str(_cli_raw or "").strip() or str(extra.get("cli_path", "") or "") ) # Channels to watch: env csv > extra list/csv; empty = all joined channels - raw_channels = os.getenv("BUZZ_CHANNELS") or extra.get("channels", []) + raw_channels = _scoped_platform_setting("BUZZ_CHANNELS", extra, "channels") + if raw_channels is None: + raw_channels = extra.get("channels", []) if isinstance(raw_channels, str): raw_channels = raw_channels.split(",") self.channels: List[str] = [c.strip() for c in raw_channels if isinstance(c, str) and c.strip()] - self.home_channel = (os.getenv("BUZZ_HOME_CHANNEL") or str(extra.get("home_channel", "") or "")).strip() + _home_raw = _scoped_platform_setting("BUZZ_HOME_CHANNEL", extra, "home_channel") + self.home_channel = (_home_raw or str(extra.get("home_channel", "") or "")).strip() + _pi_raw = _scoped_platform_setting("BUZZ_POLL_INTERVAL", extra, "poll_interval") try: - interval = float(os.getenv("BUZZ_POLL_INTERVAL") or extra.get("poll_interval", _DEFAULT_POLL_INTERVAL)) + interval = float(_pi_raw or extra.get("poll_interval", _DEFAULT_POLL_INTERVAL)) except (TypeError, ValueError): interval = _DEFAULT_POLL_INTERVAL self.poll_interval = max(_MIN_POLL_INTERVAL, interval) @@ -385,7 +429,7 @@ class BuzzAdapter(BasePlatformAdapter): # Defaults to True (respond only when addressed). Set False to make the # agent respond to every message in a watched channel. DMs always # dispatch regardless. Env (BUZZ_REQUIRE_MENTION) overrides config.yaml. - _rm_raw = os.getenv("BUZZ_REQUIRE_MENTION") + _rm_raw = _scoped_platform_setting("BUZZ_REQUIRE_MENTION", extra, "require_mention") if _rm_raw is None: _rm_cfg = extra.get("require_mention", True) else: @@ -396,13 +440,16 @@ class BuzzAdapter(BasePlatformAdapter): # "websocket" (require WS; fail connect when it can't authenticate), # or "poll" (CLI polling only). Env (BUZZ_TRANSPORT) overrides # config.yaml. + _transport_raw = _scoped_platform_setting("BUZZ_TRANSPORT", extra, "transport") _transport = ( - os.getenv("BUZZ_TRANSPORT") or str(extra.get("transport", "auto") or "auto") + _transport_raw or str(extra.get("transport", "auto") or "auto") ).strip().lower() self.transport = _transport if _transport in ("auto", "websocket", "poll") else "auto" # Auth: entries may be hex pubkeys or npubs; normalized to hex - raw_allowed = os.getenv("BUZZ_ALLOWED_USERS") or extra.get("allowed_users", []) + raw_allowed = _scoped_platform_setting("BUZZ_ALLOWED_USERS", extra, "allowed_users") + if raw_allowed is None: + raw_allowed = extra.get("allowed_users", []) if isinstance(raw_allowed, str): raw_allowed = raw_allowed.split(",") self._allowed_pubkeys: set = { @@ -441,6 +488,17 @@ class BuzzAdapter(BasePlatformAdapter): def name(self) -> str: return "Buzz" + @staticmethod + def normalize_user_id(user_id: str) -> Optional[str]: + """Normalize a Buzz user reference (hex pubkey or npub) to hex. + + Optional hook consumed by ``gateway/authz_mixin`` when matching the + profile allowlist carried in ``config.extra.allowed_users`` (#98738): + entries may be npubs while inbound ``user_id`` is always the hex + pubkey, so a plain string compare would deny listed users. + """ + return _normalize_user_ref(user_id) + # ── buzz-cli plumbing ───────────────────────────────────────────────── async def _run_cli(self, args: List[str], *, input_text: Optional[str] = None) -> Tuple[int, str, str]: @@ -1256,8 +1314,44 @@ class BuzzAdapter(BasePlatformAdapter): # Plugin registration # --------------------------------------------------------------------------- +def _profile_buzz_extra() -> dict: + """Read ``buzz.extra`` from the active profile's config.yaml (scoped path). + + Only meaningful inside a secondary profile scope, where the hermes-home + override points at that profile's home. Used by ``check_requirements`` + (which has no PlatformConfig argument) so the multiplex gate consults the + profile's own configuration instead of the process env. Best-effort: any + failure yields an empty mapping and the caller fails closed. + """ + if not _profile_scoped(): + return {} + try: + from hermes_constants import get_hermes_home + from hermes_cli.config import read_user_config_raw + + cfg = read_user_config_raw(Path(get_hermes_home()) / "config.yaml") + except Exception: + return {} + if not isinstance(cfg, dict): + return {} + buzz = ((cfg.get("gateway") or {}).get("platforms") or {}).get("buzz") + if not isinstance(buzz, dict): + return {} + extra = buzz.get("extra", buzz) + return extra if isinstance(extra, dict) else {} + + def check_requirements() -> bool: """Check if Buzz is configured: a relay URL plus a resolvable key.""" + if _profile_scoped(): + # Multiplexed secondary profile (#98738): os.environ's BUZZ_* values + # are the default profile's bridge output and must not satisfy this + # gate for another profile. Consult the profile's own config.yaml + # (via the scoped home override) and its secret scope instead; an + # unconfigured profile fails closed. + extra = _profile_buzz_extra() + relay = str(extra.get("relay_url") or "").strip() + return bool(relay and _resolve_private_key(extra)) if not os.getenv("BUZZ_RELAY_URL", "").strip(): return False return bool(_resolve_private_key()) @@ -1266,7 +1360,8 @@ def check_requirements() -> bool: def validate_config(config) -> bool: """Validate that the platform config has enough info to connect.""" extra = getattr(config, "extra", {}) or {} - relay = os.getenv("BUZZ_RELAY_URL") or extra.get("relay_url", "") + relay = _scoped_platform_setting("BUZZ_RELAY_URL", extra, "relay_url") + relay = relay if relay is not None else extra.get("relay_url", "") return bool(relay and _resolve_private_key(extra)) @@ -1291,6 +1386,12 @@ def _apply_yaml_config(yaml_cfg: dict, buzz_cfg: dict) -> Optional[dict]: extra = buzz_cfg.get("extra", buzz_cfg) or {} if not isinstance(extra, dict): return None + # Under multiplex, a secondary profile's config loads inside its runtime + # scope; its values must NOT be written to the process-global env, where + # first-writer-wins would pin them for every other profile (issue #72348 + # Telegram/Discord mirror, Buzz side of #98738). Its adapter reads the + # profile's PlatformConfig.extra directly instead. + _skip_env_bridge = _profile_scoped() _str_keys = { "relay_url": "BUZZ_RELAY_URL", "cli_path": "BUZZ_CLI_PATH", @@ -1299,24 +1400,24 @@ def _apply_yaml_config(yaml_cfg: dict, buzz_cfg: dict) -> Optional[dict]: } for src, env in _str_keys.items(): val = extra.get(src) - if val and not os.getenv(env): + if val and not _skip_env_bridge and not os.getenv(env): os.environ[env] = str(val) interval = extra.get("poll_interval") - if interval is not None and not os.getenv("BUZZ_POLL_INTERVAL"): + if interval is not None and not _skip_env_bridge and not os.getenv("BUZZ_POLL_INTERVAL"): os.environ["BUZZ_POLL_INTERVAL"] = str(interval) channels = extra.get("channels") - if channels is not None and not os.getenv("BUZZ_CHANNELS"): + if channels is not None and not _skip_env_bridge and not os.getenv("BUZZ_CHANNELS"): if isinstance(channels, (list, tuple)): channels = ",".join(str(c) for c in channels) os.environ["BUZZ_CHANNELS"] = str(channels) allowed = extra.get("allowed_users") - if allowed is not None and not os.getenv("BUZZ_ALLOWED_USERS"): + if allowed is not None and not _skip_env_bridge and not os.getenv("BUZZ_ALLOWED_USERS"): if isinstance(allowed, (list, tuple)): allowed = ",".join(str(a) for a in allowed) os.environ["BUZZ_ALLOWED_USERS"] = str(allowed) - if "allow_all_users" in extra and not os.getenv("BUZZ_ALLOW_ALL_USERS"): + if "allow_all_users" in extra and not _skip_env_bridge and not os.getenv("BUZZ_ALLOW_ALL_USERS"): os.environ["BUZZ_ALLOW_ALL_USERS"] = str(extra["allow_all_users"]).lower() - if "require_mention" in extra and not os.getenv("BUZZ_REQUIRE_MENTION"): + if "require_mention" in extra and not _skip_env_bridge and not os.getenv("BUZZ_REQUIRE_MENTION"): os.environ["BUZZ_REQUIRE_MENTION"] = str(extra["require_mention"]).lower() return None @@ -1331,6 +1432,12 @@ def _env_enablement() -> Optional[dict]: The special ``home_channel`` key is handled by the core hook — it becomes a proper ``HomeChannel`` on the ``PlatformConfig``. """ + if _profile_scoped(): + # Secondary profile scope (#98738): the process env's BUZZ_* values + # are the default profile's configuration, not this profile's — env + # enablement must not fabricate a Buzz platform for a profile that + # did not configure one. + return None relay = os.getenv("BUZZ_RELAY_URL", "").strip() if not relay or not _resolve_private_key(): return None @@ -1374,16 +1481,19 @@ async def _standalone_send( fail with ``No live adapter for platform 'buzz'``. """ extra = getattr(pconfig, "extra", {}) or {} - relay = (os.getenv("BUZZ_RELAY_URL") or extra.get("relay_url", "")).strip() + _relay_raw = _scoped_platform_setting("BUZZ_RELAY_URL", extra, "relay_url") + relay = (_relay_raw or extra.get("relay_url", "")).strip() private_key = _resolve_private_key(extra) + _cli_raw = _scoped_platform_setting("BUZZ_CLI_PATH", extra, "cli_path") cli_path = _resolve_cli_path( - os.getenv("BUZZ_CLI_PATH", "").strip() or str(extra.get("cli_path", "") or "") + str(_cli_raw or "").strip() or str(extra.get("cli_path", "") or "") ) if not relay or not private_key: return {"error": "Buzz standalone send: BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY must be configured"} if not cli_path: return {"error": "Buzz standalone send: buzz CLI binary not found"} - target = (chat_id or "").strip() or (os.getenv("BUZZ_HOME_CHANNEL") or str(extra.get("home_channel", "") or "")).strip() + _home_raw = _scoped_platform_setting("BUZZ_HOME_CHANNEL", extra, "home_channel") + target = (chat_id or "").strip() or (_home_raw or str(extra.get("home_channel", "") or "")).strip() if not target: return {"error": "Buzz standalone send: no target channel (set BUZZ_HOME_CHANNEL)"} diff --git a/tests/gateway/test_buzz_adapter.py b/tests/gateway/test_buzz_adapter.py index d612021bfe..79cdfe4e05 100644 --- a/tests/gateway/test_buzz_adapter.py +++ b/tests/gateway/test_buzz_adapter.py @@ -143,6 +143,234 @@ class TestBuzzAdapterInit: assert adapter.relay_url == "https://env.relay" +# ── Multiplex secondary-profile scope (#98738) ───────────────────────────── + + +@pytest.fixture +def multiplex_scope(): + """Install multiplex + a secondary-profile secret scope; restore after.""" + + tokens = [] + + def install(scope=None): + from agent.secret_scope import set_multiplex_active, set_secret_scope + + set_multiplex_active(True) + tokens.append(set_secret_scope(scope or {})) + return tokens[-1] + + yield install + + from agent.secret_scope import reset_secret_scope, set_multiplex_active + + for token in reversed(tokens): + reset_secret_scope(token) + set_multiplex_active(False) + + +@pytest.fixture +def default_profile_env(monkeypatch): + """The default profile's YAML-to-env bridge output in os.environ.""" + monkeypatch.setenv("BUZZ_RELAY_URL", "https://default.relay") + monkeypatch.setenv("BUZZ_CHANNELS", "chan-a,chan-b,chan-c") + monkeypatch.setenv("BUZZ_HOME_CHANNEL", "chan-a") + monkeypatch.setenv("BUZZ_POLL_INTERVAL", "9") + monkeypatch.setenv("BUZZ_CLI_PATH", "/default/bin/buzz") + monkeypatch.setenv("BUZZ_TRANSPORT", "poll") + monkeypatch.setenv("BUZZ_ALLOWED_USERS", "default-user-npub") + monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", "/default/creds.json") + monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1default") + + +class TestMultiplexProfileScope: + + def test_secondary_extra_wins_over_default_profile_env( + self, multiplex_scope, default_profile_env, tmp_path + ): + """The secondary profile's PlatformConfig is authoritative (#98738).""" + from gateway.config import PlatformConfig + + cli = tmp_path / "buzz" + cli.write_text("#!/bin/sh\n", encoding="utf-8") + multiplex_scope() + cfg = PlatformConfig( + enabled=True, + extra={ + "relay_url": "https://profile.relay", + "channels": ["pchan"], + "home_channel": "pchan", + "poll_interval": 2, + "cli_path": str(cli), + "transport": "websocket", + "allowed_users": [SELF_NPUB], + }, + ) + adapter = BuzzAdapter(cfg) + assert adapter.relay_url == "https://profile.relay" + assert adapter.channels == ["pchan"] + assert adapter.home_channel == "pchan" + assert adapter.poll_interval == 2.0 + assert adapter.cli_path == str(cli) + assert adapter.transport == "websocket" + assert adapter._allowed_pubkeys == {SELF_PUBKEY} + + def test_secondary_missing_keys_fail_closed( + self, multiplex_scope, default_profile_env + ): + """Keys absent from the profile's config must NOT borrow the default + profile's bridged env values — that would connect this adapter to the + default profile's relay and watch its channels.""" + from gateway.config import PlatformConfig + + multiplex_scope() + adapter = BuzzAdapter(PlatformConfig(enabled=True, extra={})) + assert adapter.relay_url == "" + assert adapter.channels == [] + assert adapter.home_channel == "" + assert adapter.poll_interval == _buzz_mod._DEFAULT_POLL_INTERVAL + assert adapter.transport == "auto" + assert adapter._allowed_pubkeys == set() + + def test_secondary_credentials_file_not_borrowed( + self, multiplex_scope, default_profile_env, tmp_path, monkeypatch + ): + """BUZZ_CREDENTIALS_FILE in env points at the DEFAULT profile's key + file; the scoped adapter must not read the default identity's key.""" + default_creds = tmp_path / "default-creds.json" + default_creds.write_text( + json.dumps({"nsec": "nsec1default-identity"}), encoding="utf-8" + ) + monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(default_creds)) + multiplex_scope() + # Scope has no key: the profile is unconfigured and must fail closed + # to "" rather than resolving the default profile's credentials. + assert _buzz_mod._resolve_private_key({}) == "" + + def test_default_profile_unscoped_keeps_env_precedence( + self, monkeypatch, default_profile_env + ): + """Multiplex ON but no scope (the DEFAULT profile constructs + unscoped): env is its own bridge output and still wins.""" + from agent.secret_scope import set_multiplex_active + from gateway.config import PlatformConfig + + set_multiplex_active(True) + try: + adapter = BuzzAdapter( + PlatformConfig(enabled=True, extra={"relay_url": "https://cfg.relay"}) + ) + finally: + set_multiplex_active(False) + assert adapter.relay_url == "https://default.relay" + + def test_check_requirements_scoped_reads_profile_config( + self, multiplex_scope, default_profile_env, tmp_path + ): + """The gate must consult the profile's own config.yaml + secret scope, + not the default profile's env values.""" + import yaml + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + creds = tmp_path / "creds.json" + creds.write_text(json.dumps({"nsec": "nsec1profile"}), encoding="utf-8") + (tmp_path / "config.yaml").write_text( + yaml.safe_dump( + { + "gateway": { + "platforms": { + "buzz": { + "enabled": True, + "extra": { + "relay_url": "https://profile.relay", + "credentials_file": str(creds), + }, + } + } + } + } + ), + encoding="utf-8", + ) + + multiplex_scope() + token = set_hermes_home_override(str(tmp_path)) + try: + # The default profile's env relay+key must NOT pass the gate on + # their own for a profile without a buzz config... + assert check_requirements() is True # profile config passes + finally: + reset_hermes_home_override(token) + + # A profile whose config.yaml has no buzz entry fails closed even + # though the default profile's env values are present. + empty_home = tmp_path / "empty-profile" + empty_home.mkdir() + multiplex_scope() + token = set_hermes_home_override(str(empty_home)) + try: + assert check_requirements() is False + finally: + reset_hermes_home_override(token) + + def test_env_enablement_scoped_returns_none(self, multiplex_scope, default_profile_env): + """Scoped env enablement must not fabricate Buzz for a profile from + the default profile's env values.""" + multiplex_scope() + assert _env_enablement() is None + + def test_apply_yaml_config_scoped_skips_env_bridge( + self, multiplex_scope, default_profile_env, monkeypatch + ): + """A secondary profile's YAML values must not be pinned into the + process env for every other profile (first-writer-wins).""" + for var in ("BUZZ_RELAY_URL", "BUZZ_HOME_CHANNEL", "BUZZ_CHANNELS"): + monkeypatch.delenv(var, raising=False) + multiplex_scope() + _buzz_mod._apply_yaml_config( + {}, + {"extra": {"relay_url": "https://profile.relay", "home_channel": "pchan"}}, + ) + import os as _os + + assert "BUZZ_RELAY_URL" not in _os.environ + assert "BUZZ_HOME_CHANNEL" not in _os.environ + assert "BUZZ_CHANNELS" not in _os.environ + + def test_standalone_send_scoped_uses_profile_extra( + self, multiplex_scope, default_profile_env, monkeypatch, tmp_path + ): + multiplex_scope() + from gateway.config import PlatformConfig + + cli = tmp_path / "buzz" + cli.write_text("#!/bin/sh\n", encoding="utf-8") + calls = {} + + async def fake_exec(cli_path, args, *, relay_url, private_key, input_text=None, timeout=None): + calls["relay"] = relay_url + return 0, '{"event_id": "e1"}', "" + + monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) + monkeypatch.setattr( + _buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1profile" + ) + result = asyncio.run( + _standalone_send( + PlatformConfig( + enabled=True, + extra={"relay_url": "https://profile.relay", "cli_path": str(cli)}, + ), + "chan-x", + "hello", + ) + ) + assert result.get("success") is True + assert calls["relay"] == "https://profile.relay" + + # ── CLI error contract ──────────────────────────────────────────────────── diff --git a/tests/gateway/test_multiplex_profile_authz.py b/tests/gateway/test_multiplex_profile_authz.py index 6694c7be8d..ea7142ee47 100644 --- a/tests/gateway/test_multiplex_profile_authz.py +++ b/tests/gateway/test_multiplex_profile_authz.py @@ -168,3 +168,113 @@ def test_secondary_open_policy_fails_startup_guard(monkeypatch): assert violation is not None assert "wecom" in violation assert "open policy" in violation + + +# ───────────────────────────────────────────────────────────────────── +# Plugin-platform extra.allowed_users fallback (#98738 / #82871) +# ───────────────────────────────────────────────────────────────────── + + +def _make_buzz_multiplex_runner(monkeypatch, extra): + """Runner whose secondary 'coder' profile runs a live Buzz adapter.""" + from gateway.run import GatewayRunner + from tests.gateway.test_buzz_adapter import _normalize_user_ref + + for key in ( + "BUZZ_ALLOWED_USERS", + "BUZZ_ALLOW_ALL_USERS", + "GATEWAY_ALLOWED_USERS", + "GATEWAY_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(key, raising=False) + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=True) + + adapter = SimpleNamespace( + config=PlatformConfig(enabled=True, extra=extra), + # The Buzz adapter exposes this hook so npub allowlist entries match + # the hex-pubkey user ids the gateway authorizes. + normalize_user_id=_normalize_user_ref, + ) + runner.adapters = {} + runner._profile_adapters = {"coder": {Platform.BUZZ: adapter}} + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + return runner + + +def _buzz_source(user_id): + return SessionSource( + platform=Platform.BUZZ, + user_id=user_id, + chat_id="chat-1", + user_name="member", + chat_type="dm", + profile="coder", + ) + + +def _patch_buzz_registry(monkeypatch, allowed_users_env="BUZZ_ALLOWED_USERS"): + from gateway.platform_registry import platform_registry + + real_get = platform_registry.get + + def _get(key): + if key == "buzz": + return SimpleNamespace(allowed_users_env=allowed_users_env) + return real_get(key) + + monkeypatch.setattr(platform_registry, "get", _get) + + +def test_secondary_buzz_extra_allowed_users_authorizes_listed_user(monkeypatch): + """A secondary profile's extra.allowed_users must authorize its users when + the env var only ever carried the default profile's list (#98738/#82871).""" + from tests.gateway.test_buzz_adapter import SELF_NPUB, SELF_PUBKEY + + runner = _make_buzz_multiplex_runner( + monkeypatch, extra={"allowed_users": [SELF_NPUB]} + ) + _patch_buzz_registry(monkeypatch) + + # user_id arrives as the hex pubkey while the allowlist entry is an npub. + assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is True + + +def test_secondary_buzz_extra_allowed_users_denies_unlisted_sender(monkeypatch): + """Default-deny is preserved: a sender not in the profile's allowlist + stays denied even though the adapter-level list admitted the message.""" + from tests.gateway.test_buzz_adapter import SELF_PUBKEY + + runner = _make_buzz_multiplex_runner( + monkeypatch, extra={"allowed_users": ["npub1" + "b" * 56]} + ) + _patch_buzz_registry(monkeypatch) + + assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False + + +def test_secondary_buzz_without_extra_allowlist_stays_default_deny(monkeypatch): + """No extra.allowed_users configured: nothing changes, the default-deny + path applies (no fail-open via an empty list).""" + from tests.gateway.test_buzz_adapter import SELF_PUBKEY + + runner = _make_buzz_multiplex_runner(monkeypatch, extra={}) + _patch_buzz_registry(monkeypatch) + + assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False + + +def test_extra_allowed_users_not_consulted_without_registry_declaration(monkeypatch): + """The fallback is gated on the platform's registry entry declaring + allowed_users_env — a platform without that contract keeps the previous + behavior even if its extra happens to hold an allowed_users key.""" + from tests.gateway.test_buzz_adapter import SELF_PUBKEY + + runner = _make_buzz_multiplex_runner( + monkeypatch, extra={"allowed_users": ["someone"]} + ) + _patch_buzz_registry(monkeypatch, allowed_users_env="") + + assert runner._is_user_authorized(_buzz_source("someone")) is False