fix(platforms): adapter settings resolve explicit env → own YAML → default, per profile

One reader (gateway.platforms._shared.extra_or_secret) now implements the
precedence every per-profile setting follows for the OWNING profile:
explicit scoped env/.env → that profile's config.yaml (PlatformConfig.extra)
→ the adapter's default. A scoped miss returns the default, never the launch
process's os.environ; single-profile / default-profile installs keep the
documented env-over-YAML contract.

Why: 545e74d0ea (#108705) stopped bridging a secondary's YAML into the
process env and moved readers to config.extra, but the shared reader and the
hand-rolled helpers in Discord/Slack/Matrix/Telegram consulted YAML FIRST and
then fell back to a scoped env read. Two bug classes followed (#108440
post-merge review by andrexibiza, #109032):
- an explicit env value could no longer beat YAML for the owning profile
  (DISCORD_ALLOW_MENTION_EVERYONE=false lost to allow_mentions.everyone: true;
  TELEGRAM_REACTIONS=true lost to the stock reactions: false);
- a secondary that OMITTED a key inherited the launch profile's bridged env
  through the fallback (Matrix process_notices/session_scope, Discord
  auto_thread/reactions/mentions, Slack reactions/ignored_channels).

Consumers migrated to the shared reader: Discord _build_allowed_mentions and
_extra_or_env_flag; Slack _slack_allow_bots, _reactions_enabled (the
_extra_or_env_* getters already used it); Matrix _extra_truthy, _extra_csv_set,
session_scope, reactions, require_mention parsers, and — new — the
allowed_users / ignore_user_patterns consumers that never read the seeded YAML
lists; Telegram _extra_bool, _extra_str_set, _reactions_enabled; Feishu
allow_bots; WhatsApp dm_policy/group_policy.

Refs #108440, #109032
This commit is contained in:
teknium1
2026-09-13 13:53:10 -07:00
committed by Teknium
parent 27180c8d2c
commit 3dedb71f2f
9 changed files with 125 additions and 82 deletions
+18 -15
View File
@@ -271,8 +271,8 @@ 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 (
env_is_connected as _env_is_connected, platform_gate_env as _scoped_gate_env, send_error,
yaml_env_setter as _yaml_env_setter
env_is_connected as _env_is_connected, extra_or_secret as _extra_or_secret,
platform_gate_env as _scoped_gate_env, send_error, yaml_env_setter as _yaml_env_setter
)
@@ -613,9 +613,12 @@ def _build_allowed_mentions(extra: Optional[dict] = None):
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)
# Explicit (scoped) env → this profile's YAML → safe default; a scoped miss never reads
# another profile's bridged env, and an explicit ``=false`` beats ``everyone: true``.
raw = _extra_or_secret(configured, key, name, None)
if raw is None:
return default
return raw if isinstance(raw, bool) else str(raw).strip().lower() in {"true", "1", "yes", "on"}
return discord.AllowedMentions(
everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", "everyone", False),
@@ -4563,17 +4566,17 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter):
return resolve_channel_prompt(self.config.extra, channel_id, parent_id)
def _extra_or_env_flag(self, key: str, env_key: str, env_default: str, *, truthy: bool) -> bool:
"""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."""
"""Boolean: explicit scoped ``env_key`` → ``config.extra[key]`` (str parsed permissively) →
``env_default``. ``truthy=True`` values must be in {true,1,yes,on}; ``truthy=False`` values are
on unless in {false,0,no,off} — matching each flag's historical default shape."""
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"}
return bool(configured)
env = _scoped_gate_env(env_key, env_default).lower()
return env in {"true", "1", "yes", "on"} if truthy else env not in {"false", "0", "no", "off"}
configured = _extra_or_secret(extra if isinstance(extra, dict) else None, key, env_key, None)
if configured is None:
configured = env_default
if isinstance(configured, bool):
return configured
text = str(configured).strip().lower()
return text in {"true", "1", "yes", "on"} if truthy else text not in {"false", "0", "no", "off"}
def _discord_require_mention(self) -> bool:
"""Return whether Discord channel messages require a bot mention."""
+1 -1
View File
@@ -1293,7 +1293,7 @@ class FeishuAdapter(BasePlatformAdapter):
# 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 = str(_get_scoped_secret("FEISHU_ALLOW_BOTS", "") or extra.get("allow_bots") or "none").strip().lower()
allow_bots = str(_extra_or_secret("allow_bots", "FEISHU_ALLOW_BOTS", "none") 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.",
+16 -20
View File
@@ -476,9 +476,8 @@ def _csv_set(raw: Any) -> Set[str]:
def _extra_csv_set(config, key: str, env_name: str) -> Set[str]:
"""Resolve a room/user list from config.extra[key] (blank = unset), else the scoped env var —
under multiplex os.environ is the DEFAULT profile's room/user list."""
return _csv_set(_extra_or_secret(config.extra, key, env_name))
"""Resolve a room/user list: scoped env var → config.extra[key] → empty."""
return _csv_set(_extra_or_secret(config.extra, key, env_name, "", blank_is_unset=False))
def _recovery_key_output_path() -> Optional[Path]:
@@ -819,10 +818,10 @@ class MatrixAdapter(BasePlatformAdapter):
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 = self._extra_truthy(config, "dm_mention_threads", "MATRIX_DM_MENTION_THREADS", "false")
raw_session_scope = str(config.extra.get("session_scope") or _get_scoped_secret("MATRIX_SESSION_SCOPE", "auto")).strip().lower()
raw_session_scope = str(_extra_or_secret(config.extra, "session_scope", "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 = self._extra_truthy(config, "process_notices", "MATRIX_PROCESS_NOTICES", "false")
self._reactions_enabled: bool = str(_get_scoped_secret("MATRIX_REACTIONS", "true")).lower() not in {"false", "0", "no"}
self._reactions_enabled: bool = str(_extra_or_secret(config.extra, "reactions", "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
# clients). 5s is empirically safe; if it must be tunable, use config.yaml not env.
@@ -844,12 +843,13 @@ class MatrixAdapter(BasePlatformAdapter):
self._approval_timeout_seconds = _env_number("MATRIX_APPROVAL_TIMEOUT_SECONDS", 300, int)
self._model_picker_prompts_by_event: Dict[str, _MatrixPickerPrompt] = {}
self._choice_picker_prompts_by_event: Dict[str, _MatrixPickerPrompt] = {}
# Authz lists via the scoped reader: under multiplex os.environ is the DEFAULT profile's
# allowlist, which must not decide who approves tool calls on a secondary bot.
self._allowed_user_ids: Set[str] = _csv_set(_get_scoped_secret("MATRIX_ALLOWED_USERS", "").strip())
# Authz lists: scoped env → this profile's YAML (``allowed_users`` / ``ignore_user_patterns``,
# seeded by the bridge) → empty. Under multiplex os.environ is the DEFAULT profile's allowlist,
# which must not decide who approves tool calls on a secondary bot.
self._allowed_user_ids: Set[str] = _extra_csv_set(config, "allowed_users", "MATRIX_ALLOWED_USERS")
self._allowed_room_ids: Set[str] = set(self._allowed_rooms)
self._ignored_user_patterns: list[re.Pattern[str]] = []
for pattern in (p.strip() for p in _get_scoped_secret("MATRIX_IGNORE_USER_PATTERNS", "").strip().split(",") if p.strip()):
for pattern in _csv_set(_extra_or_secret(config.extra, "ignore_user_patterns", "MATRIX_IGNORE_USER_PATTERNS", "")):
try:
self._ignored_user_patterns.append(re.compile(pattern))
except re.error as exc:
@@ -869,7 +869,7 @@ class MatrixAdapter(BasePlatformAdapter):
@staticmethod
def _extra_truthy(config, key: str, env_name: str, default: str) -> bool:
"""``config.extra[key]`` (YAML-bridged, per profile; blank = unset) else the env var, true/1/yes."""
"""Scoped env var → ``config.extra[key]`` (YAML, per profile) → ``default``; true/1/yes semantics."""
configured = _extra_or_secret(config.extra, key, env_name, default)
return configured if isinstance(configured, bool) else str(configured).lower() in ("true", "1", "yes")
@@ -887,19 +887,15 @@ class MatrixAdapter(BasePlatformAdapter):
@staticmethod
def _parse_require_mention(config) -> bool:
"""require_mention from config.extra, else MATRIX_REQUIRE_MENTION (default true)."""
configured = MatrixAdapter._configured_bool(config, "require_mention")
if configured is not None:
return configured
return str(_get_scoped_secret("MATRIX_REQUIRE_MENTION", "true")).lower() not in {"false", "0", "no", "off"}
"""MATRIX_REQUIRE_MENTION (scoped) → ``require_mention`` in config.extra → true."""
configured = _extra_or_secret(config.extra, "require_mention", "MATRIX_REQUIRE_MENTION", True)
return configured if isinstance(configured, bool) else str(configured).lower() not in {"false", "0", "no", "off"}
@staticmethod
def _parse_thread_require_mention(config) -> bool:
"""thread_require_mention from config.extra, else MATRIX_THREAD_REQUIRE_MENTION (default false)."""
configured = MatrixAdapter._configured_bool(config, "thread_require_mention")
if configured is not None:
return configured
return str(_get_scoped_secret("MATRIX_THREAD_REQUIRE_MENTION", "false")).lower() in {"true", "1", "yes", "on"}
"""MATRIX_THREAD_REQUIRE_MENTION (scoped) → ``thread_require_mention`` in config.extra → false."""
configured = _extra_or_secret(config.extra, "thread_require_mention", "MATRIX_THREAD_REQUIRE_MENTION", False)
return configured if isinstance(configured, bool) else str(configured).lower() not in {"false", "0", "no", "off"}
@staticmethod
def _extract_server_ed25519(device_keys_obj: Any) -> Optional[str]:
+4 -7
View File
@@ -2567,9 +2567,8 @@ class SlackAdapter(BasePlatformAdapter):
pass
def _slack_allow_bots(self) -> str:
"""Return normalized Slack bot-message policy."""
# Scoped read: under multiplex os.environ is the DEFAULT profile's bot-admission policy.
raw = self.config.extra.get("allow_bots", "") or _get_scoped_secret("SLACK_ALLOW_BOTS", "none")
"""Return normalized Slack bot-message policy (scoped ``SLACK_ALLOW_BOTS`` → YAML → none)."""
raw = _extra_or_secret(self.config.extra, "allow_bots", "SLACK_ALLOW_BOTS", "none")
value = str(raw).lower().strip()
if value not in {"none", "mentions", "all"}:
logger.warning("[Slack] Unknown allow_bots=%r; treating as 'none'", raw)
@@ -2974,10 +2973,8 @@ class SlackAdapter(BasePlatformAdapter):
return await self._react(channel, timestamp, emoji, team_id, remove=True)
def _reactions_enabled(self) -> bool:
"""Whether message reactions are enabled (``extra.reactions`` / ``SLACK_REACTIONS``)."""
configured = self.config.extra.get("reactions")
if configured is None:
configured = _get_scoped_secret("SLACK_REACTIONS", "true")
"""Whether message reactions are enabled (scoped ``SLACK_REACTIONS`` → ``extra.reactions`` → on)."""
configured = _extra_or_secret(self.config.extra, "reactions", "SLACK_REACTIONS", "true")
return str(configured).lower() not in {"false", "0", "no"}
def _reacting_target(self, event: MessageEvent) -> Optional[Tuple[str, str, Any]]:
+15 -21
View File
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
from agent.deadline import run_bounded_async
from gateway.platforms._shared import (
decode_json_list_literal as _decode_json_list_literal,
get_scoped_secret as _get_scoped_secret,
extra_or_secret as _extra_or_secret, get_scoped_secret as _get_scoped_secret,
platform_gate_env as _scoped_gate_env,
)
@@ -5046,22 +5046,20 @@ class TelegramAdapter(BasePlatformAdapter):
# ── Group mention gating ──────────────────────────────────────────────
def _extra_bool(self, key: str, env_name: str, default: str, *fallback_keys: str) -> bool:
"""Boolean gate from ``config.extra[key]`` (then ``fallback_keys``), else env var."""
configured = self.config.extra.get(key)
"""Boolean gate: scoped ``env_name`` → ``config.extra[key]`` (then ``fallback_keys``) → ``default``."""
configured = _extra_or_secret(self.config.extra, key, env_name, None)
for alt in fallback_keys:
if configured is None:
configured = self.config.extra.get(alt)
if configured is not None:
if isinstance(configured, str):
return configured.lower() in {"true", "1", "yes", "on"}
return bool(configured)
return _scoped_gate_env(env_name, default).lower() in {"true", "1", "yes", "on"}
if configured is None:
configured = default
if isinstance(configured, bool):
return configured
return str(configured).strip().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."""
raw = self.config.extra.get(key)
if raw is None:
raw = _scoped_gate_env(env_name)
"""Comma/list allowlist: scoped ``env_name`` → ``config.extra[key]`` → empty."""
raw = _extra_or_secret(self.config.extra, key, env_name, "", blank_is_unset=False)
raw = _decode_json_list_literal(raw)
if isinstance(raw, list):
return {str(part).strip() for part in raw if str(part).strip()}
@@ -6392,17 +6390,13 @@ class TelegramAdapter(BasePlatformAdapter):
# -- Message reactions (processing lifecycle) --
def _reactions_enabled(self) -> bool:
"""Reactions enabled via TELEGRAM_REACTIONS or ``extra.reactions`` (YAML, per profile).
"""Reactions: scoped ``TELEGRAM_REACTIONS`` → ``extra.reactions`` (YAML, per profile) → off.
An explicitly set env var wins over YAML — the same rule ``yaml_env_setter`` documents for
the YAML→env bridge — so the stock ``reactions: false`` every install materializes cannot
silently kill a documented ``TELEGRAM_REACTIONS=true`` (#109032). Under multiplex a scoped
miss returns the default instead of another profile's process-env value (#72348), so only
a scoped/env hit counts as explicit; otherwise the profile's own YAML decides.
An explicit env var wins over YAML, so the stock ``reactions: false`` every install
materializes cannot silently kill a documented ``TELEGRAM_REACTIONS=true`` (#109032). Under
multiplex a scoped miss falls to the profile's own YAML, never another profile's env (#72348).
"""
configured = _scoped_gate_env("TELEGRAM_REACTIONS", "")
if not configured:
configured = self.config.extra.get("reactions")
configured = _extra_or_secret(self.config.extra, "reactions", "TELEGRAM_REACTIONS", None)
if configured is None:
return False
return str(configured).lower() not in {"false", "0", "no"}