Files
hermes-agent/gateway/platforms/_shared.py
Teknium 545e74d0ea 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>
2026-09-11 15:29:15 -07:00

76 lines
3.1 KiB
Python

"""Cross-adapter helpers shared by gateway/platforms/* and plugins/platforms/*.
Kept dependency-light (stdlib + ``agent.secret_scope``) so every adapter can
import it at module top level without cycles.
"""
from __future__ import annotations
import os
from typing import Any, Callable
# Profile-scoped secret reader for multiplexing support (PR #50094)
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def get_scoped_secret(name: str, default: Any = None) -> Any:
"""Scope-aware credential read with the default-profile startup fallback.
An installed profile secret scope is authoritative: a scoped miss returns
``default`` (never borrow another profile's value from ``os.environ``).
The DEFAULT profile constructs and sends *unscoped* under multiplexing,
where a bare ``get_secret`` raises ``UnscopedSecretError``; there
``os.environ`` is that profile's own value, so fall back to it.
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
def profile_scoped() -> bool:
# --------------------------------------------------------------------------- YAML → env config bridge
# (apply_yaml_config_fn, #25443)
# ---------------------------------------------------------------------------
"""True when running inside a multiplexed secondary profile's scope.
Secondary-profile adapters are constructed/connected inside
``_profile_runtime_scope`` (secret scope installed + multiplex active).
The DEFAULT profile under multiplexing runs unscoped and keeps the legacy
``os.environ`` precedence, so YAML->env bridges must skip only when True.
"""
try:
from agent.secret_scope import current_secret_scope, is_multiplex_active
return bool(is_multiplex_active() and current_secret_scope() is not None)
except Exception:
return False
def 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:
return int(value)
except (TypeError, ValueError):
return default