fix(gateway): a secondary profile's config.yaml no longer poisons the process env under multiplex
Under gateway.multiplex_profiles every secondary profile's config loads inside _profile_runtime_scope, yet every apply_yaml_config_fn hook (feishu, matrix, whatsapp, slack, dingtalk, discord non-gate keys, telegram non-gate keys) and gateway/config_loader.py::bridge_core_env_settings still wrote os.environ there. First-writer-wins: the first secondary with a require_mention / allowlist / allow_bots / reactions block made that policy the DEFAULT profile's (live: TELEGRAM_REQUIRE_MENTION written from a secondary load), and secondaries read the default's env for the same keys. - gateway/platforms/_shared.py::yaml_env_setter: the one env-write shape for YAML->env bridges — env wins, skipped under an active secondary scope. - Every hook now seeds its values into the profile's PlatformConfig.extra and uses yaml_env_setter; bridge_core_env_settings seeds telegram/signal require_mention into extra and skips the env write under scope. - Readers that bypassed extra/scope now consult extra first (matrix flags + session_scope, slack reactions, telegram reactions/mention_patterns/_extra_bool, discord reactions/auto_thread/history_backfill/approval_mentions/allow_mentions, feishu allow_bots, dingtalk mention_patterns, signal require_mention). Salvages the shape of PR #100604 (whatsapp, earliest report #80099), #100435 (discord) and #100448 (telegram) by @nftpoetrist on top of current main. Fixes #80099 Co-authored-by: nftpoetrist <264138787+nftpoetrist@users.noreply.github.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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 = {}
|
||||
Reference in New Issue
Block a user