fix(buzz): secondary multiplex profiles must not inherit the default profile's env

Under gateway.multiplex_profiles the default profile's YAML-to-env bridge
writes BUZZ_* values into os.environ, and every Buzz read gave that env
precedence over the secondary profile's PlatformConfig — so each secondary
adapter connected as the default identity, watched its channels, and
resolved its credentials file (#98738).

- Add _profile_scoped()/_scoped_platform_setting(): inside a secondary
  profile scope extra is authoritative and env is not consulted (a missing
  key fails closed to its default instead of borrowing the default
  profile's value); single-profile and unscoped/default-profile reads keep
  the legacy env-over-config precedence.
- Apply the scoped read to BuzzAdapter.__init__ (relay, CLI path, channels,
  home channel, poll interval, require_mention, transport, allowed users),
  _resolve_private_key (BUZZ_CREDENTIALS_FILE), validate_config,
  _standalone_send, and check_requirements (which now consults the
  profile's own config.yaml via the scoped home override).
- _env_enablement() returns None inside a profile scope and
  _apply_yaml_config() skips the env bridge there, so the default profile's
  env cannot fabricate Buzz for a profile that never configured it and a
  secondary profile's YAML cannot be pinned into the process env
  (first-writer-wins, #72348 Telegram/Discord mirror).
- Central authorization now consults a plugin platform's live-adapter
  config.extra.allowed_users (gated on the registry entry declaring
  allowed_users_env, with an optional normalize_user_id hook so Buzz npub
  entries match hex-pubkey user ids) — under multiplex only the default
  profile's list ever reached the env var, so listed secondary-profile
  users were default-denied (#82871). Empty/absent lists change nothing;
  default-deny is preserved.
This commit is contained in:
liuhao1024
2026-08-31 02:08:33 +08:00
committed by Teknium
parent 8edaa25746
commit aaa5f27d0f
4 changed files with 506 additions and 20 deletions
+38
View File
@@ -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
+130 -20
View File
@@ -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)"}
+228
View File
@@ -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 ────────────────────────────────────────────────────
@@ -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