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:
@@ -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
|
||||
|
||||
@@ -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)"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user