diff --git a/gateway/config_loader.py b/gateway/config_loader.py index fd276eb431..491685da5b 100644 --- a/gateway/config_loader.py +++ b/gateway/config_loader.py @@ -313,7 +313,15 @@ def bridge_core_env_settings(yaml_cfg: dict, platforms_data: dict) -> None: Top-level ``require_mention`` → Telegram when the ``telegram:`` section has none: users write it alongside ``group_sessions_per_user`` expecting it to work, and the telegram plugin's hook only runs when a telegram block exists. Signal ``require_mention`` → ``SIGNAL_REQUIRE_MENTION`` (env wins). + + Both values are ALWAYS seeded into the owning platform's ``extra`` (the adapters read extra first); + the process-env write is skipped while a multiplexed secondary profile's scope is active — the + loader runs inside ``_profile_runtime_scope`` for every secondary, and a first-writer-wins write + there would make the secondary's mention policy the DEFAULT profile's (#80099 class). """ + from gateway.platforms._shared import profile_scoped + + skip_env_bridge = profile_scoped() tl_require_mention = yaml_cfg.get("require_mention") if tl_require_mention is not None and "require_mention" not in (yaml_cfg.get("telegram") or {}): tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {}) @@ -323,7 +331,7 @@ def bridge_core_env_settings(yaml_cfg: dict, platforms_data: dict) -> None: # require_mention (not a telegram: block), so the telegram plugin's apply_yaml_config_fn hook — # which only runs when a telegram config block exists — can't cover the no-telegram-block case # (#3979). - if not os.getenv("TELEGRAM_REQUIRE_MENTION"): + if not skip_env_bridge and not os.getenv("TELEGRAM_REQUIRE_MENTION"): os.environ["TELEGRAM_REQUIRE_MENTION"] = str(tl_require_mention).lower() # Telegram settings → env vars / extra: migrated to the telegram plugin's apply_yaml_config_fn hook @@ -331,8 +339,11 @@ def bridge_core_env_settings(yaml_cfg: dict, platforms_data: dict) -> None: # WhatsApp settings → env vars: migrated to the whatsapp plugin's apply_yaml_config_fn hook # (plugins/platforms/whatsapp/adapter.py). #41112 / #3823. signal_cfg = yaml_cfg.get("signal", {}) - if isinstance(signal_cfg, dict) and "require_mention" in signal_cfg and not os.getenv("SIGNAL_REQUIRE_MENTION"): - os.environ["SIGNAL_REQUIRE_MENTION"] = str(signal_cfg["require_mention"]).lower() + if isinstance(signal_cfg, dict) and "require_mention" in signal_cfg: + sig_plat = platforms_data.setdefault(Platform.SIGNAL.value, {}) + sig_plat.setdefault("extra", {}).setdefault("require_mention", signal_cfg["require_mention"]) + if not skip_env_bridge and not os.getenv("SIGNAL_REQUIRE_MENTION"): + os.environ["SIGNAL_REQUIRE_MENTION"] = str(signal_cfg["require_mention"]).lower() def read_yaml_layers(home: Path) -> dict: diff --git a/gateway/platforms/_shared.py b/gateway/platforms/_shared.py index 3edb326d32..202cc3e3ec 100644 --- a/gateway/platforms/_shared.py +++ b/gateway/platforms/_shared.py @@ -7,7 +7,7 @@ import it at module top level without cycles. from __future__ import annotations import os -from typing import Any +from typing import Any, Callable # Profile-scoped secret reader for multiplexing support (PR #50094) from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError @@ -48,6 +48,25 @@ def profile_scoped() -> bool: return False +def yaml_env_setter() -> Callable[[str, Any], None]: + """``set_env(name, value)`` for ``apply_yaml_config_fn`` hooks: writes ``os.environ[name]`` only + when the var is unset (explicit env wins over YAML) and NEVER while a multiplexed secondary + profile's scope is active — the gateway loads every secondary's config inside + ``_profile_runtime_scope``, so a write there would pin that profile's policy process-wide and the + default profile's adapters would read it as their own (first-writer-wins poisoning, #80099). + Hooks seed the same values into the returned ``extra`` so each profile's adapter reads its own. + Lists are comma-joined; ``None`` is skipped. + """ + skip = profile_scoped() + + def set_env(name: str, value: Any) -> None: + if value is None or skip or os.getenv(name): + return + os.environ[name] = ",".join(str(v) for v in value) if isinstance(value, list) else str(value) + + return set_env + + def coerce_port(value: Any, default: int) -> int: """``int(value)`` or ``default`` when unparseable.""" try: diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 4fce8d22cb..1772f349d0 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -192,7 +192,7 @@ class SignalAdapter(BasePlatformAdapter): self.group_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_GROUP_ALLOWED_USERS", ""))) _rm_cfg = extra.get("require_mention") self.require_mention = (bool(_rm_cfg) if _rm_cfg is not None - else os.getenv("SIGNAL_REQUIRE_MENTION", "false").lower() in TRUTHY_STRINGS) + else (_sig_secret("SIGNAL_REQUIRE_MENTION", "false") or "false").lower() in TRUTHY_STRINGS) self.dm_allow_from = set(_parse_comma_list(_sig_secret("SIGNAL_ALLOWED_USERS", "*"))) self.client: Optional[httpx.AsyncClient] = None self._sse_task: Optional[asyncio.Task] = None diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index 1d73eae067..215296773e 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -50,7 +50,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator, compile_mention_patterns from gateway.platforms.base import BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, yaml_env_setter as _yaml_env_setter from plugins.platforms.dingtalk.inbound import collect_download_codes, extract_media, extract_text @@ -273,7 +273,7 @@ class DingTalkAdapter(BasePlatformAdapter): def _compile_mention_patterns(self) -> List[re.Pattern]: """Compile optional regex wake-word patterns (config list, or env as JSON / lines / CSV).""" patterns = self._extra_get("mention_patterns") - if patterns is None and (raw := os.getenv("DINGTALK_MENTION_PATTERNS", "").strip()): + if patterns is None and (raw := str(_get_scoped_secret("DINGTALK_MENTION_PATTERNS", "") or "").strip()): try: patterns = json.loads(raw) except Exception: @@ -690,12 +690,6 @@ def _manual_credential_entry(prompt, save_env_value, print_success) -> None: print_success("DingTalk credentials saved") -def _bridge_list_env(env_name: str, value) -> None: - """Export a YAML list/scalar as a comma-joined env var unless the env var is already set.""" - if value is not None and not os.getenv(env_name): - os.environ[env_name] = ",".join(str(v) for v in value) if isinstance(value, list) else str(value) - - def _nested_allowed_users(yaml_cfg: dict, dingtalk_cfg: dict): """Allowlist from ``extra.allowed_users``: this block's own extra first, then ``gateway.platforms.dingtalk.extra`` and ``platforms.dingtalk.extra``.""" _gw = yaml_cfg.get("gateway") @@ -708,21 +702,28 @@ def _nested_allowed_users(yaml_cfg: dict, dingtalk_cfg: dict): def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None: - """Translate config.yaml dingtalk: keys into DINGTALK_* env vars (apply_yaml_config_fn); env wins, returns None. The docs put the allowlist at - ``gateway.platforms.dingtalk.extra.allowed_users`` but gateway authz only consults DINGTALK_ALLOWED_USERS, so nested-only allowlists are bridged too. + """Translate config.yaml dingtalk: keys into DINGTALK_* env vars + ``PlatformConfig.extra`` (apply_yaml_config_fn); + env wins. The docs put the allowlist at ``gateway.platforms.dingtalk.extra.allowed_users`` but gateway authz only + consults DINGTALK_ALLOWED_USERS, so nested-only allowlists are bridged too. Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy dingtalk_cfg block from - gateway/config.py::load_gateway_config(). Env vars take precedence over YAML (each assignment guarded by - not os.getenv(...)). Returns None — everything flows through env. + gateway/config.py::load_gateway_config(). The env write is skipped under a multiplexed secondary profile's + scope; the adapter's ``_extra_get`` readers consume the seeded extra. """ + _set_env = _yaml_env_setter() + seeded: dict = {} for key, env, encode in (("require_mention", "DINGTALK_REQUIRE_MENTION", lambda v: str(v).lower()), ("mention_patterns", "DINGTALK_MENTION_PATTERNS", json.dumps)): - if key in dingtalk_cfg and not os.getenv(env): - os.environ[env] = encode(dingtalk_cfg[key]) + if key in dingtalk_cfg: + seeded[key] = dingtalk_cfg[key] + _set_env(env, encode(dingtalk_cfg[key])) allowed = dingtalk_cfg.get("allowed_users") - for env, value in (("DINGTALK_FREE_RESPONSE_CHATS", dingtalk_cfg.get("free_response_chats")), ("DINGTALK_ALLOWED_CHATS", dingtalk_cfg.get("allowed_chats")), - ("DINGTALK_ALLOWED_USERS", _nested_allowed_users(yaml_cfg, dingtalk_cfg) if allowed is None else allowed)): - _bridge_list_env(env, value) - return None + for key, env, value in (("free_response_chats", "DINGTALK_FREE_RESPONSE_CHATS", dingtalk_cfg.get("free_response_chats")), + ("allowed_chats", "DINGTALK_ALLOWED_CHATS", dingtalk_cfg.get("allowed_chats")), + ("allowed_users", "DINGTALK_ALLOWED_USERS", _nested_allowed_users(yaml_cfg, dingtalk_cfg) if allowed is None else allowed)): + if value is not None: + seeded[key] = value + _set_env(env, value) + return seeded or None def _is_connected(config) -> bool: diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 0489c16656..88a18180b8 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -268,7 +268,7 @@ from gateway.platforms.base import ( ) from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome from tools.url_safety import is_safe_url -from gateway.platforms._shared import profile_scoped as _profile_scoped_config_load +from gateway.platforms._shared import yaml_env_setter as _yaml_env_setter async def _read_url_image_with_redirect_guard( @@ -602,11 +602,12 @@ def check_discord_requirements() -> bool: return True -def _build_allowed_mentions(): +def _build_allowed_mentions(extra: Optional[dict] = None): """Build Discord ``AllowedMentions`` denying @everyone/@here/roles by default (any LLM output with ``@everyone`` would otherwise ping the server); user / replied-user pings stay on. - Override via env (or ``discord.allow_mentions.*`` in config.yaml): + Override via ``discord.allow_mentions.*`` in config.yaml (``extra["allow_mentions"]``, per profile) + or env — a secondary multiplex profile never sees the default profile's env (#72348): DISCORD_ALLOW_MENTION_EVERYONE default false — @everyone + @here DISCORD_ALLOW_MENTION_ROLES default false — @role pings @@ -615,12 +616,19 @@ def _build_allowed_mentions(): """ if not DISCORD_AVAILABLE: return None - _b = _env_bool + configured = (extra or {}).get("allow_mentions") + configured = configured if isinstance(configured, dict) else {} + + def _b(name: str, key: str, default: bool) -> bool: + if (raw := configured.get(key)) is not None: + return str(raw).strip().lower() in {"true", "1", "yes", "on"} + return _env_bool(name, default) + return discord.AllowedMentions( - everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", False), - roles=_b("DISCORD_ALLOW_MENTION_ROLES", False), - users=_b("DISCORD_ALLOW_MENTION_USERS", True), - replied_user=_b("DISCORD_ALLOW_MENTION_REPLIED_USER", True), + everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", "everyone", False), + roles=_b("DISCORD_ALLOW_MENTION_ROLES", "roles", False), + users=_b("DISCORD_ALLOW_MENTION_USERS", "users", True), + replied_user=_b("DISCORD_ALLOW_MENTION_REPLIED_USER", "replied_user", True), ) @@ -1218,7 +1226,7 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, - allowed_mentions=_build_allowed_mentions(), + allowed_mentions=_build_allowed_mentions(getattr(self.config, "extra", None)), **proxy_kwargs_for_bot(proxy_url), ) adapter_self = self # capture for closure @@ -2745,8 +2753,8 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): return False def _reactions_enabled(self) -> bool: - """Check if message reactions are enabled via config/env.""" - return os.getenv("DISCORD_REACTIONS", "true").lower() not in {"false", "0", "no"} + """Reactions enabled via ``extra.reactions`` (YAML, per profile) or ``DISCORD_REACTIONS``.""" + return self._extra_or_env_flag("reactions", "DISCORD_REACTIONS", "true", truthy=False) async def on_processing_start(self, event: MessageEvent) -> None: """Add an in-progress reaction and record durable handling state.""" @@ -4555,7 +4563,8 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): """Boolean from ``config.extra[key]`` (str parsed permissively) else ``env_key``. ``truthy=True`` env values must be in {true,1,yes,on}; ``truthy=False`` env values are on unless in {false,0,no,off} — matching each flag's historical default shape.""" - configured = self.config.extra.get(key) + extra = getattr(self.config, "extra", None) + configured = extra.get(key) if isinstance(extra, dict) else None if configured is not None: if isinstance(configured, str): return configured.lower() not in {"false", "0", "no", "off"} @@ -4774,10 +4783,7 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): def _discord_history_backfill(self) -> bool: """Return whether history backfill is enabled for shared sessions.""" - configured = self.config.extra.get("history_backfill") - if configured is not None: - return self._extra_or_env_flag("history_backfill", "DISCORD_HISTORY_BACKFILL", "true", truthy=True) - return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in {"true", "1", "yes"} + return self._extra_or_env_flag("history_backfill", "DISCORD_HISTORY_BACKFILL", "true", truthy=True) def _discord_history_backfill_limit(self) -> int: """Max messages scanned backwards; a safety cap since scans usually stop at the bot's last message.""" @@ -5203,7 +5209,7 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): def _approval_mention_content(self) -> Optional[str]: """User mentions for approval prompts, gated on ``discord.approval_mentions`` (``DISCORD_APPROVAL_MENTIONS``). Only numeric allowlist entries; default off.""" - if not _env_bool("DISCORD_APPROVAL_MENTIONS", False): + if not self._extra_or_env_flag("approval_mentions", "DISCORD_APPROVAL_MENTIONS", "false", truthy=True): return None user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit()) if not user_ids: @@ -5727,7 +5733,7 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): if not is_thread and not isinstance(message.channel, discord.DMChannel): no_thread_channels = self._get_no_thread_channels() skip_thread = bool(channel_keys & no_thread_channels) or is_free_channel - auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"} + auto_thread = self._extra_or_env_flag("auto_thread", "DISCORD_AUTO_THREAD", "true", truthy=True) is_reply_message = getattr(message, "type", None) == discord.MessageType.reply if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: thread = await self._auto_create_thread(message) @@ -6955,16 +6961,18 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: Implements the ``apply_yaml_config_fn`` contract (#24836). Mirrors the legacy ``discord_cfg`` block that used to live in ``gateway/config.py::load_gateway_config()`` before this migration. """ - def _env_default(env_key: str, value) -> None: - # First-writer-wins: an explicit env var always beats the YAML value. - if not os.getenv(env_key): - os.environ[env_key] = value + # Every env write is first-writer-wins (an explicit env var beats YAML) and is skipped for a + # profile-scoped multiplex load: a secondary profile's settings must never land in process-global + # env where they'd become another profile's policy (#72348). Everything is seeded into extra too. + _env_default = _yaml_env_setter() def _csv(value) -> str: return ",".join(str(v) for v in value) if isinstance(value, list) else str(value) + seeded_extra = {} for key, env_key in _YAML_BOOL_ENV_KEYS: if key in discord_cfg: + seeded_extra[key] = discord_cfg[key] # original type: the shared-key loop seeds bools as bools _env_default(env_key, str(discord_cfg[key]).lower()) platforms_cfg = yaml_cfg.get("platforms") platform_extra_cfg = {} @@ -6974,13 +6982,6 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: candidate_extra = discord_platform_cfg.get("extra") if isinstance(candidate_extra, dict): platform_extra_cfg = candidate_extra - seeded_extra = {} - # Gate keys are ALWAYS seeded into PlatformConfig.extra (per-profile lists); the os.environ writes - # below are first-writer-wins for legacy consumers and skipped for profile-scoped multiplex loads. - # The os.environ writes below remain first-writer-wins for legacy env-only consumers, but are skipped - # for profile-scoped loads under multiplex — a secondary profile's gates must never land in - # process-global env where they'd become another profile's policy. See #72348. - _skip_env_bridge = _profile_scoped_config_load() def _gate(key: str, env_key: str, *, from_platform_extra: bool, lower: bool = False) -> None: value = discord_cfg[key] if key in discord_cfg else (platform_extra_cfg.get(key) if from_platform_extra else None) @@ -6988,8 +6989,7 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: return text = str(value).lower() if lower else _csv(value) seeded_extra[key] = text - if not _skip_env_bridge: - _env_default(env_key, text) + _env_default(env_key, text) _gate("allow_from", "DISCORD_ALLOWED_USERS", from_platform_extra=True) _gate("allowed_roles", "DISCORD_ALLOWED_ROLES", from_platform_extra=True) @@ -7000,10 +7000,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: else platform_extra_cfg.get("approval_mentions") ) if approval_mentions_cfg is not None: + seeded_extra["approval_mentions"] = approval_mentions_cfg _env_default("DISCORD_APPROVAL_MENTIONS", str(approval_mentions_cfg).lower()) _gate("free_response_channels", "DISCORD_FREE_RESPONSE_CHANNELS", from_platform_extra=False) for key, env_key in (("auto_thread", "DISCORD_AUTO_THREAD"), ("reactions", "DISCORD_REACTIONS")): if key in discord_cfg: + seeded_extra[key] = discord_cfg[key] _env_default(env_key, str(discord_cfg[key]).lower()) backfill_cfg = discord_cfg.get("missed_message_backfill") if isinstance(backfill_cfg, dict): @@ -7013,13 +7015,16 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: _gate("no_thread_channels", "DISCORD_NO_THREAD_CHANNELS", from_platform_extra=False) # history_backfill: recover mention-gated channel messages between bot turns. if "history_backfill" in discord_cfg: + seeded_extra["history_backfill"] = discord_cfg["history_backfill"] _env_default("DISCORD_HISTORY_BACKFILL", str(discord_cfg["history_backfill"]).lower()) hbl = discord_cfg.get("history_backfill_limit") if hbl is not None: + seeded_extra["history_backfill_limit"] = hbl _env_default("DISCORD_HISTORY_BACKFILL_LIMIT", str(hbl)) # allow_mentions: safe defaults live in the adapter; these keys only override when set. allow_mentions_cfg = discord_cfg.get("allow_mentions") if isinstance(allow_mentions_cfg, dict): + seeded_extra["allow_mentions"] = dict(allow_mentions_cfg) for yaml_key in ("everyone", "roles", "users", "replied_user"): if yaml_key in allow_mentions_cfg: _env_default(f"DISCORD_ALLOW_MENTION_{yaml_key.upper()}", str(allow_mentions_cfg[yaml_key]).lower()) @@ -7037,8 +7042,8 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: value = _websocket_liveness_cfg.get(legacy_key) if value is not None: seeded_extra[primary_key] = value - if env_key and not os.getenv(env_key): - os.environ[env_key] = str(value) + if env_key: + _env_default(env_key, str(value)) return seeded_extra or None diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 584eb17b7c..a3be3a6efb 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -92,7 +92,7 @@ from gateway.status import acquire_scoped_lock, release_scoped_lock from hermes_constants import get_hermes_home from utils import atomic_json_write, env_float, env_int -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, yaml_env_setter as _yaml_env_setter logger = logging.getLogger(__name__) @@ -1283,11 +1283,10 @@ class FeishuAdapter(BasePlatformAdapter): require_mention=_to_boolean(rule_cfg["require_mention"]) if "require_mention" in rule_cfg else None, ) - # Env-only so adapter and gateway auth bypass share one source (yaml feishu.allow_bots - # is bridged to the env var at config load). Scoped read: under multiplex a secondary - # profile's .env must govern its own adapter. + # Scoped read: under multiplex a secondary profile's .env must govern its own adapter; yaml + # feishu.allow_bots reaches it via ``extra`` (the env bridge is skipped under its scope). # See #86905. - allow_bots = _get_scoped_secret("FEISHU_ALLOW_BOTS", "none").strip().lower() + allow_bots = str(_get_scoped_secret("FEISHU_ALLOW_BOTS", "") or extra.get("allow_bots") or "none").strip().lower() if allow_bots not in {"none", "mentions", "all"}: logger.warning( "[Feishu] Unknown allow_bots=%r, falling back to 'none'. Valid: none, mentions, all.", @@ -4295,14 +4294,17 @@ def interactive_setup() -> None: def _apply_yaml_config(yaml_cfg: dict, feishu_cfg: dict) -> dict | None: - """apply_yaml_config_fn: bridge config.yaml feishu.allow_bots to FEISHU_ALLOW_BOTS (env wins); returns None. + """apply_yaml_config_fn: bridge config.yaml feishu.allow_bots to FEISHU_ALLOW_BOTS (env wins) and seed + ``extra.allow_bots`` so a multiplexed secondary profile's adapter reads its own value. Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy feishu_cfg block from gateway/config.py::load_gateway_config() (allow_bots). Env vars take precedence over YAML. """ - if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"): - os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower() - return None + if "allow_bots" not in feishu_cfg: + return None + _set_env = _yaml_env_setter() + _set_env("FEISHU_ALLOW_BOTS", str(feishu_cfg["allow_bots"]).lower()) + return {"allow_bots": str(feishu_cfg["allow_bots"]).lower()} def _is_connected(config) -> bool: diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 02e3d8e0af..55ba3c0dad 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -38,6 +38,7 @@ from pathlib import Path from typing import Any, Dict, Optional, Set from agent.secret_scope import UnscopedSecretError, get_secret +from gateway.platforms._shared import yaml_env_setter as _yaml_env_setter try: from mautrix.types import ( @@ -853,12 +854,14 @@ class MatrixAdapter(BasePlatformAdapter): # If non-empty, bot ONLY responds in these rooms (whitelist); DMs exempt. self._allowed_rooms: Set[str] = _extra_csv_set(config, "allowed_rooms", "MATRIX_ALLOWED_ROOMS") self._allow_room_mentions: bool = _env_truthy("MATRIX_ALLOW_ROOM_MENTIONS", "false") - self._auto_thread: bool = _env_truthy("MATRIX_AUTO_THREAD", "true") + # Extra-first: the YAML bridge seeds these into extra and skips the env write under a + # multiplexed secondary scope, where os.environ holds the DEFAULT profile's flags. + self._auto_thread: bool = self._extra_truthy(config, "auto_thread", "MATRIX_AUTO_THREAD", "true") self._dm_auto_thread: bool = _env_truthy("MATRIX_DM_AUTO_THREAD", "false") - self._dm_mention_threads: bool = _env_truthy("MATRIX_DM_MENTION_THREADS", "false") - raw_session_scope = os.getenv("MATRIX_SESSION_SCOPE", "auto").strip().lower() + self._dm_mention_threads: bool = self._extra_truthy(config, "dm_mention_threads", "MATRIX_DM_MENTION_THREADS", "false") + raw_session_scope = str(config.extra.get("session_scope") or os.getenv("MATRIX_SESSION_SCOPE", "auto")).strip().lower() self._matrix_session_scope = raw_session_scope if raw_session_scope in {"auto", "room", "thread"} else "auto" - self._process_notices: bool = _env_truthy("MATRIX_PROCESS_NOTICES", "false") + self._process_notices: bool = self._extra_truthy(config, "process_notices", "MATRIX_PROCESS_NOTICES", "false") self._reactions_enabled: bool = os.getenv("MATRIX_REACTIONS", "true").lower() not in {"false", "0", "no"} self._pending_reactions: dict[tuple[str, str], str] = {} # Let the final message land before redacting reactions ("missing event" in some @@ -906,6 +909,14 @@ class MatrixAdapter(BasePlatformAdapter): self._processed_events_set.add(event_id) return False + @staticmethod + def _extra_truthy(config, key: str, env_name: str, default: str) -> bool: + """``config.extra[key]`` (YAML-bridged, per profile) else the env var, true/1/yes semantics.""" + configured = config.extra.get(key) + if configured is None: + return _env_truthy(env_name, default) + return configured if isinstance(configured, bool) else str(configured).lower() in ("true", "1", "yes") + @staticmethod def _configured_bool(config, key: str) -> Optional[bool]: """Parse a YAML bool / "true"/"off"-style string from config.extra; None if unset.""" @@ -3033,25 +3044,28 @@ _YAML_LIST_KEYS = ( def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None: - """apply_yaml_config_fn: config.yaml matrix: keys → MATRIX_* env (env wins). Returns None. Lowercased - flags apply whenever the key is present (None still writes "none"); list-valued keys skip None. + """apply_yaml_config_fn: config.yaml matrix: keys → MATRIX_* env (env wins) + ``PlatformConfig.extra``. + Lowercased flags apply whenever the key is present (None still writes "none"); list-valued keys skip None. Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy matrix_cfg block from - gateway/config.py::load_gateway_config(). Env vars take precedence over YAML. Returns None — everything - flows through env. + gateway/config.py::load_gateway_config(). The env write is skipped under a multiplexed secondary + profile's scope; the seeded ``extra`` is what its adapter reads (extra-first readers). """ + _set_env = _yaml_env_setter() + seeded: dict = {} for key, env_name in _YAML_LOWER_KEYS: - if key in matrix_cfg and not os.getenv(env_name): - os.environ[env_name] = str(matrix_cfg[key]).lower() + if key in matrix_cfg: + seeded[key] = matrix_cfg[key] + _set_env(env_name, str(matrix_cfg[key]).lower()) for key, env_name in _YAML_LIST_KEYS: value = matrix_cfg.get(key) - if value is not None and not os.getenv(env_name): - if isinstance(value, list): - value = ",".join(str(v) for v in value) - os.environ[env_name] = str(value) - if "max_message_length" in matrix_cfg and not os.getenv("MATRIX_MAX_MESSAGE_LENGTH"): - os.environ["MATRIX_MAX_MESSAGE_LENGTH"] = str(matrix_cfg["max_message_length"]) - return None + if value is not None: + seeded[key] = value + _set_env(env_name, value) + if "max_message_length" in matrix_cfg: + seeded["max_message_length"] = matrix_cfg["max_message_length"] + _set_env("MATRIX_MAX_MESSAGE_LENGTH", str(matrix_cfg["max_message_length"])) + return seeded or None def _is_connected(config) -> bool: diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index b6eecc4537..6d55a9922c 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -35,7 +35,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[3])) from agent.secret_scope import UnscopedSecretError, get_secret from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, yaml_env_setter as _yaml_env_setter from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, SendResult, SUPPORTED_DOCUMENT_TYPES, SUPPORTED_VIDEO_TYPES, _TEXT_INJECT_EXTENSIONS, @@ -2938,8 +2938,11 @@ class SlackAdapter(BasePlatformAdapter): return await self._react(channel, timestamp, emoji, team_id, remove=True) def _reactions_enabled(self) -> bool: - """Whether message reactions are enabled (``SLACK_REACTIONS`` env).""" - return os.getenv("SLACK_REACTIONS", "true").lower() not in {"false", "0", "no"} + """Whether message reactions are enabled (``extra.reactions`` / ``SLACK_REACTIONS``).""" + configured = self.config.extra.get("reactions") + if configured is None: + configured = os.getenv("SLACK_REACTIONS", "true") + return str(configured).lower() not in {"false", "0", "no"} def _reacting_target(self, event: MessageEvent) -> Optional[Tuple[str, str, Any]]: """``(ts, team_id, marker)`` when reactions are on and ``event`` is being tracked.""" @@ -6452,22 +6455,27 @@ _YAML_LIST_KEYS = ( def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None: - """``apply_yaml_config_fn`` hook: ``slack:`` YAML keys → ``SLACK_*`` env vars (the adapter reads - ``os.getenv()``; explicit env wins). Returns None: nothing is seeded into ``extra``. + """``apply_yaml_config_fn`` hook: ``slack:`` YAML keys → ``SLACK_*`` env vars (explicit env wins) and + ``PlatformConfig.extra`` (extra-first readers; the env write is skipped under a multiplexed + secondary profile's scope so its policy never becomes the default profile's). Implements the ``apply_yaml_config_fn`` contract (#24849). Mirrors the legacy ``slack_cfg`` block that used to live in ``gateway/config.py::load_gateway_config()`` before this migration. """ + _set_env = _yaml_env_setter() + seeded: dict = {} for key, env in _YAML_BOOL_KEYS: - if key in slack_cfg and not os.getenv(env): - os.environ[env] = str(slack_cfg[key]).lower() + if key in slack_cfg: + seeded[key] = slack_cfg[key] # original type: the shared-key loop already seeded bools as bools + _set_env(env, str(slack_cfg[key]).lower()) for key, env, list_types in _YAML_LIST_KEYS: val = slack_cfg.get(key) - if val is not None and not os.getenv(env): + if val is not None: + seeded[key] = val if list_types and isinstance(val, list_types): val = ",".join(str(v) for v in val) - os.environ[env] = str(val) - return None + _set_env(env, str(val)) + return seeded or None def _is_connected(config) -> bool: diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 986465ea57..0085982926 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -5027,7 +5027,7 @@ class TelegramAdapter(BasePlatformAdapter): if isinstance(configured, str): return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv(env_name, default).lower() in {"true", "1", "yes", "on"} + return _scoped_gate_env(env_name, default).lower() in {"true", "1", "yes", "on"} def _extra_str_set(self, key: str, env_name: str) -> set[str]: """Comma/list allowlist from ``config.extra[key]``, else the profile-scoped env var.""" @@ -5122,7 +5122,7 @@ class TelegramAdapter(BasePlatformAdapter): """Compile optional regex wake-word patterns for group triggers.""" patterns = self.config.extra.get("mention_patterns") if patterns is None: - raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip() + raw = _scoped_gate_env("TELEGRAM_MENTION_PATTERNS", "").strip() if raw: try: loaded = json.loads(raw) @@ -6354,8 +6354,11 @@ class TelegramAdapter(BasePlatformAdapter): # -- Message reactions (processing lifecycle) -- def _reactions_enabled(self) -> bool: - """Reactions enabled via TELEGRAM_REACTIONS env/config.""" - return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"} + """Reactions enabled via ``extra.reactions`` (YAML, per profile) or TELEGRAM_REACTIONS.""" + configured = self.config.extra.get("reactions") + if configured is None: + configured = _scoped_gate_env("TELEGRAM_REACTIONS", "false") + return str(configured).lower() not in {"false", "0", "no"} async def _set_reaction(self, chat_id: str, message_id: str, emoji: Optional[str]) -> bool: """Set a single emoji reaction (``None`` clears all bot-set reactions, the documented Bot API way).""" @@ -6478,34 +6481,25 @@ def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None: gateway/config.py::load_gateway_config(). """ import json as _json + from gateway.platforms._shared import yaml_env_setter extras: dict = {} - # Under multiplex a secondary profile's authorization gates must NOT hit the process-global env - # (first-writer-wins would pin them for every profile); they flow via extra/secret scope. - try: - # See #72348. - from agent.secret_scope import current_secret_scope, is_multiplex_active - _skip_env_bridge = bool(is_multiplex_active() and current_secret_scope() is not None) - except Exception: - _skip_env_bridge = False - - def _set_env(env: str, value: str) -> None: - if not os.getenv(env): - os.environ[env] = value + # Under multiplex a secondary profile's settings must NOT hit the process-global env (first-writer-wins + # would pin them for every profile, #72348); yaml_env_setter skips the write under its scope and the + # values flow via extra/secret scope instead. + _set_env = yaml_env_setter() def _bridge_lower(key: str, env: str) -> None: if key in telegram_cfg: + extras.setdefault(key, telegram_cfg[key]) _set_env(env, str(telegram_cfg[key]).lower()) def _bridge_gate(key: str, env: str, value: Any, *, seed_extra: bool = False) -> None: - """CSV allowlist gate: list → comma-joined; skipped under multiplex secret scope.""" + """CSV allowlist gate: list → comma-joined; env write skipped under multiplex secret scope.""" if value is None: return if seed_extra: extras.setdefault(key, value) - if isinstance(value, list): - value = ",".join(str(v) for v in value) - if not _skip_env_bridge: - _set_env(env, str(value)) + _set_env(env, value) if "disable_topic_auto_rename" in telegram_cfg: extras.setdefault("disable_topic_auto_rename", telegram_cfg["disable_topic_auto_rename"]) diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 7ce812384d..d001c0215f 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -13,7 +13,7 @@ from functools import wraps from pathlib import Path from typing import Dict, Optional, Any -from gateway.platforms._shared import get_scoped_secret +from gateway.platforms._shared import get_scoped_secret, yaml_env_setter from hermes_cli._subprocess_compat import windows_detach_popen_kwargs from hermes_constants import (find_node_executable, get_hermes_dir, with_hermes_node_path) @@ -924,22 +924,29 @@ _YAML_LIST_KEYS = (("free_response_chats", "WHATSAPP_FREE_RESPONSE_CHATS"), ("al def _apply_yaml_config(yaml_cfg: dict, whatsapp_cfg: dict) -> dict | None: - """config.yaml whatsapp: keys → WHATSAPP_* env vars (apply_yaml_config_fn contract; returns None). + """config.yaml whatsapp: keys → WHATSAPP_* env vars + ``PlatformConfig.extra`` (apply_yaml_config_fn). Mirrors the legacy whatsapp_cfg block from gateway/config.py::load_gateway_config(). Env vars take - precedence over YAML. Returns None — everything flows through env. See #24849. + precedence over YAML. The env write is skipped under a multiplexed secondary profile's scope (#80099); + every field has an extra-first reader (``WhatsAppAdapter.__init__`` policies/allowlists, + ``whatsapp_common`` require_mention/free_response_chats/mention_patterns). See #24849. """ import json as _json + _set_env = yaml_env_setter() + seeded: dict = {} for key, env in _YAML_LOWERCASE_KEYS: - if key in whatsapp_cfg and not os.getenv(env): - os.environ[env] = str(whatsapp_cfg[key]).lower() - if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"): - os.environ["WHATSAPP_MENTION_PATTERNS"] = _json.dumps(whatsapp_cfg["mention_patterns"]) + if key in whatsapp_cfg: + seeded[key] = whatsapp_cfg[key] + _set_env(env, str(whatsapp_cfg[key]).lower()) + if "mention_patterns" in whatsapp_cfg: + seeded["mention_patterns"] = whatsapp_cfg["mention_patterns"] + _set_env("WHATSAPP_MENTION_PATTERNS", _json.dumps(whatsapp_cfg["mention_patterns"])) for key, env in _YAML_LIST_KEYS: val = whatsapp_cfg.get(key) - if val is not None and not os.getenv(env): - os.environ[env] = ",".join(str(v) for v in val) if isinstance(val, list) else str(val) - return None + if val is not None: + seeded[key] = val + _set_env(env, val) + return seeded or None def _is_connected(config) -> bool: diff --git a/tests/gateway/test_multiplex_process_global_sinks.py b/tests/gateway/test_multiplex_process_global_sinks.py new file mode 100644 index 0000000000..87a0127ab3 --- /dev/null +++ b/tests/gateway/test_multiplex_process_global_sinks.py @@ -0,0 +1,74 @@ +"""Per-profile isolation for the remaining process-global sinks under gateway.multiplex_profiles: +Yuanbao auto-sethome env write, the config ``terminal.env_passthrough`` allowlist, and the runner-level +Slack ignored-channel fail-safe (which only had the DEFAULT profile's GatewayConfig).""" +from __future__ import annotations + +import os + +from agent.secret_scope import reset_secret_scope, set_multiplex_active, set_secret_scope +from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + +def _under_secondary(home, fn): + set_multiplex_active(True) + home_token = set_hermes_home_override(str(home)) + secret_token = set_secret_scope({}) + try: + return fn() + finally: + reset_secret_scope(secret_token) + reset_hermes_home_override(home_token) + set_multiplex_active(False) + + +def test_yuanbao_auto_sethome_from_secondary_stays_in_its_config(tmp_path, monkeypatch): + from gateway.platforms.yuanbao import AutoSetHomeMiddleware + + monkeypatch.delenv("YUANBAO_HOME_CHANNEL", raising=False) + secondary = tmp_path / "profiles" / "b2" + secondary.mkdir(parents=True) + (secondary / "config.yaml").write_text("{}\n") + + class Adapter: + name = "yuanbao-b2" + + class Ctx: + chat_id = "dm:tenant-b2" + chat_name = "b2" + + _under_secondary(secondary, lambda: AutoSetHomeMiddleware._persist_home(Adapter(), Ctx())) + + assert "YUANBAO_HOME_CHANNEL" not in os.environ + assert "dm:tenant-b2" in (secondary / "config.yaml").read_text() + + +def test_env_passthrough_allowlist_follows_the_active_profile(tmp_path, monkeypatch): + import tools.env_passthrough as ep + + default_home = tmp_path / "hermes" + secondary = default_home / "profiles" / "b2" + secondary.mkdir(parents=True) + (default_home / "config.yaml").write_text("terminal:\n env_passthrough: [FOO_DEFAULT]\n") + (secondary / "config.yaml").write_text("terminal:\n env_passthrough: [FOO_B2]\n") + monkeypatch.setenv("HERMES_HOME", str(default_home)) + ep._config_passthrough.clear() + + assert ep._load_config_passthrough() == {"FOO_DEFAULT"} + assert _under_secondary(secondary, ep._load_config_passthrough) == {"FOO_B2"} + assert ep._load_config_passthrough() == {"FOO_DEFAULT"} + + +def test_slack_ignored_channels_use_the_routed_adapters_list(monkeypatch): + from gateway.config import GatewayConfig, Platform, PlatformConfig + from gateway.run import _is_slack_ignored_channel + + monkeypatch.delenv("SLACK_IGNORED_CHANNELS", raising=False) + default_cfg = GatewayConfig(platforms={Platform.SLACK: PlatformConfig(enabled=True, extra={"ignored_channels": ["C_DEFAULT"]})}) + + class SecondaryAdapter: + config = PlatformConfig(enabled=True, extra={"ignored_channels": ["C_B2"]}) + + assert _is_slack_ignored_channel(default_cfg, "C_B2", SecondaryAdapter()) + assert not _is_slack_ignored_channel(default_cfg, "C_DEFAULT", SecondaryAdapter()) + # No routed adapter (legacy callers): the process-level config still rules. + assert _is_slack_ignored_channel(default_cfg, "C_DEFAULT") diff --git a/tests/gateway/test_multiplex_yaml_env_bridge_isolation.py b/tests/gateway/test_multiplex_yaml_env_bridge_isolation.py new file mode 100644 index 0000000000..27dde12775 --- /dev/null +++ b/tests/gateway/test_multiplex_yaml_env_bridge_isolation.py @@ -0,0 +1,101 @@ +"""A multiplexed secondary profile's config.yaml must never be bridged into the process env. + +``_load_secondary_profile_config`` runs ``load_gateway_config()`` inside ``_profile_runtime_scope``; +every ``apply_yaml_config_fn`` hook and ``bridge_core_env_settings`` used to write ``os.environ`` there +(first-writer-wins), so the first secondary's mention/allowlist policy became the DEFAULT profile's +(#80099, #72348). The values must instead reach that profile's ``PlatformConfig.extra``. +""" +from __future__ import annotations + +import os + +import pytest + +from agent.secret_scope import ( + reset_secret_scope, + set_multiplex_active, + set_secret_scope, +) +from hermes_constants import reset_hermes_home_override, set_hermes_home_override + +_SECONDARY_YAML = """\ +require_mention: false +telegram: + mention_patterns: ['^bot2'] + reactions: false +matrix: + require_mention: false + allowed_users: ['@b2:example.org'] + session_scope: room +whatsapp: + dm_policy: open + allow_from: ['+15550002'] +feishu: + allow_bots: all +slack: + allow_bots: all + ignored_channels: [C_B2] +dingtalk: + allowed_users: [b2user] +discord: + auto_thread: false + reactions: false +signal: + require_mention: false +""" + +_BRIDGED_ENV = ( + "TELEGRAM_REQUIRE_MENTION", "TELEGRAM_MENTION_PATTERNS", "TELEGRAM_REACTIONS", + "MATRIX_REQUIRE_MENTION", "MATRIX_ALLOWED_USERS", "MATRIX_SESSION_SCOPE", + "WHATSAPP_DM_POLICY", "WHATSAPP_ALLOWED_USERS", "FEISHU_ALLOW_BOTS", + "SLACK_ALLOW_BOTS", "SLACK_IGNORED_CHANNELS", "DINGTALK_ALLOWED_USERS", + "DISCORD_AUTO_THREAD", "DISCORD_REACTIONS", "SIGNAL_REQUIRE_MENTION", +) + +_EXPECTED_EXTRA = ( + ("telegram", "mention_patterns"), ("telegram", "reactions"), ("matrix", "session_scope"), + ("matrix", "allowed_users"), ("whatsapp", "dm_policy"), ("feishu", "allow_bots"), + ("slack", "allow_bots"), ("slack", "ignored_channels"), ("dingtalk", "allowed_users"), + ("discord", "auto_thread"), ("signal", "require_mention"), +) + + +@pytest.fixture +def secondary_scope(tmp_path, monkeypatch): + default_home = tmp_path / "hermes" + secondary = default_home / "profiles" / "bot2" + secondary.mkdir(parents=True) + (default_home / "config.yaml").write_text("gateway:\n multiplex_profiles: true\n") + (secondary / "config.yaml").write_text(_SECONDARY_YAML) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + for name in _BRIDGED_ENV: + monkeypatch.delenv(name, raising=False) + set_multiplex_active(True) + home_token = set_hermes_home_override(str(secondary)) + secret_token = set_secret_scope({"TELEGRAM_BOT_TOKEN": "222:b2"}) + try: + yield + finally: + reset_secret_scope(secret_token) + reset_hermes_home_override(home_token) + set_multiplex_active(False) + + +def test_secondary_profile_yaml_reaches_its_extra_not_the_process_env(secondary_scope): + from hermes_cli.plugins import discover_plugins + from gateway.config import Platform, load_gateway_config + + discover_plugins() + cfg = load_gateway_config() + + poisoned = {name: os.environ[name] for name in _BRIDGED_ENV if name in os.environ} + assert poisoned == {}, f"secondary profile wrote into process env: {poisoned}" + missing = [ + f"{plat}.{key}" for plat, key in _EXPECTED_EXTRA + if key not in ((cfg.platforms.get(Platform(plat)) or _Empty()).extra or {}) + ] + assert missing == [], f"secondary profile's own YAML not seeded into extra: {missing}" + + +class _Empty: + extra: dict = {}