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
251 lines
11 KiB
Python
251 lines
11 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 contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import Any, Callable, Iterable, Optional
|
|
|
|
# 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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_scoped_secret(name: str, default: Any = None, *, external_fallback: bool = False) -> 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.
|
|
|
|
``external_fallback`` adds one rung for startup gates that run before any
|
|
scope exists: an unscoped miss consults a one-shot build of the profile's
|
|
own secret scope, so externally managed credentials (Bitwarden etc., only
|
|
``BWS_ACCESS_TOKEN`` in ``.env``) are visible to ``check_requirements``.
|
|
"""
|
|
try:
|
|
val = _scoped_get_secret(name, None)
|
|
except _UnscopedSecretError:
|
|
val = os.getenv(name)
|
|
if val is None and external_fallback and _current_scope() is None:
|
|
val = _unscoped_profile_secrets().get(name)
|
|
return val if val is not None else default
|
|
|
|
|
|
def _current_scope():
|
|
from agent.secret_scope import current_secret_scope
|
|
return current_secret_scope()
|
|
|
|
|
|
_UNSCOPED_PROFILE_SECRETS: Optional[dict] = None
|
|
|
|
|
|
def _unscoped_profile_secrets() -> dict:
|
|
"""Process-cached profile secret mapping (external resolvers are slow); failures degrade to {}."""
|
|
global _UNSCOPED_PROFILE_SECRETS
|
|
if _UNSCOPED_PROFILE_SECRETS is None:
|
|
try:
|
|
from agent.secret_scope import build_profile_secret_scope
|
|
from hermes_constants import get_hermes_home
|
|
_UNSCOPED_PROFILE_SECRETS = dict(build_profile_secret_scope(get_hermes_home()))
|
|
except Exception:
|
|
logger.warning(
|
|
"Could not build the profile secret scope; externally managed credentials will not be "
|
|
"visible to the startup gate (#95216)", exc_info=True)
|
|
_UNSCOPED_PROFILE_SECRETS = {}
|
|
return _UNSCOPED_PROFILE_SECRETS
|
|
|
|
|
|
def platform_gate_env(name: str, default: str = "") -> str:
|
|
"""Allow/deny gate env read with per-profile isolation, always stripped.
|
|
|
|
With a profile secret scope installed AND multiplexing active, a scoped miss returns ``default``
|
|
instead of falling through to ``os.environ``, which may hold ANOTHER profile's first-writer
|
|
bridged value (the YAML→env bridges are first-writer-wins; allowlist leak, #72348).
|
|
Single-profile deployments behave exactly like ``os.getenv``.
|
|
"""
|
|
if not name:
|
|
return default
|
|
with contextlib.suppress(Exception):
|
|
from agent.secret_scope import current_secret_scope, is_multiplex_active
|
|
|
|
scope = current_secret_scope()
|
|
if scope is not None and is_multiplex_active():
|
|
val = scope.get(name)
|
|
return default if val is None else str(val).strip()
|
|
return (os.getenv(name) or default).strip()
|
|
|
|
|
|
def decode_json_list_literal(raw):
|
|
"""Decode a JSON-encoded allowlist written by ``hermes config set``.
|
|
|
|
String-typed defaults keep list literals verbatim on write (``allowed_chats`` is
|
|
declared as ``""``), so the config can hold ``'["-100","-200"]'`` as a string.
|
|
Malformed JSON passes through unchanged and keeps the legacy comma-split path.
|
|
"""
|
|
if isinstance(raw, str) and raw.lstrip()[:1] == "[":
|
|
try:
|
|
loaded = json.loads(raw)
|
|
except ValueError:
|
|
return raw
|
|
if isinstance(loaded, list):
|
|
return loaded
|
|
return raw
|
|
|
|
|
|
def extra_or_secret(extra: Optional[dict], key: str, env: str, default: Any = "",
|
|
*, blank_is_unset: bool = True) -> Any:
|
|
"""The ONE per-profile setting reader: explicit env ``env`` → the profile's YAML
|
|
``config.extra[key]`` → ``default``.
|
|
|
|
The env rung is the owning profile's, read through ``get_scoped_secret``: a secondary
|
|
multiplex profile sees its own ``.env`` and a miss falls to ITS YAML, never to the launch
|
|
process's ``os.environ`` (which holds the default profile's bridged values); single-profile
|
|
and default-profile installs read ``os.environ`` there, keeping the documented env-over-YAML
|
|
contract (an explicit ``DISCORD_ALLOW_MENTION_EVERYONE=false`` beats ``everyone: true``,
|
|
#108440; ``TELEGRAM_REACTIONS=true`` beats the stock ``reactions: false``, #109032). A blank
|
|
env value is unset. An explicit ``False``/``0`` in YAML is a real value (``require_mention:
|
|
false`` must not fall to ``default``). A blank YAML string is unset by default; readers whose
|
|
YAML key means "clear it" (``allowed_channels: "" `` = no whitelist) pass
|
|
``blank_is_unset=False`` so only a missing/``None`` key falls through.
|
|
"""
|
|
if env:
|
|
env_value = get_scoped_secret(env, None)
|
|
if env_value is not None and str(env_value).strip():
|
|
return env_value
|
|
value = (extra or {}).get(key)
|
|
if value is None or (blank_is_unset and isinstance(value, str) and not value.strip()):
|
|
return default
|
|
return value
|
|
|
|
|
|
def profile_scoped() -> bool:
|
|
"""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
|
|
|
|
|
|
# --------------------------------------------------------------------------- YAML → env config bridge
|
|
# (apply_yaml_config_fn, #25443)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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 send_error(message: Any) -> dict:
|
|
"""Standalone-sender failure envelope with vendor exception text redacted (the same helper
|
|
``send_message`` uses), so a token or signed URL in an httpx/aiohttp error never reaches the
|
|
model transcript."""
|
|
from tools.send_message_senders import _error
|
|
return _error(str(message))
|
|
|
|
|
|
# kind -> (applies-when predicate over (cfg, key), env encoder). "lower"/"json" bridge whenever the key
|
|
# is present (YAML ``none`` still writes "none"); "str" skips null/blank, "csv" skips null and leaves
|
|
# lists to the setter's comma join.
|
|
_YAML_KINDS: dict[str, tuple[Callable[[dict, str], bool], Callable[[Any], Any]]] = {
|
|
"lower": (lambda cfg, key: key in cfg, lambda v: str(v).lower()),
|
|
"str": (lambda cfg, key: cfg.get(key) not in (None, ""), str),
|
|
"csv": (lambda cfg, key: cfg.get(key) is not None, lambda v: v),
|
|
"json": (lambda cfg, key: key in cfg, json.dumps),
|
|
}
|
|
|
|
|
|
def apply_yaml_bridge(cfg: dict, spec: Iterable[tuple[str, str, str]]) -> dict | None:
|
|
"""Table-driven ``apply_yaml_config_fn`` body: for each ``(yaml_key, ENV_VAR, kind)`` row seed the
|
|
original YAML value into the returned ``extra`` and bridge it to env through ``yaml_env_setter``
|
|
(env wins; skipped under a secondary profile's scope). ``None`` when nothing matched.
|
|
"""
|
|
set_env = yaml_env_setter()
|
|
seeded: dict = {}
|
|
for key, env, kind in spec:
|
|
applies, encode = _YAML_KINDS[kind]
|
|
if applies(cfg, key):
|
|
seeded[key] = cfg[key]
|
|
set_env(env, encode(cfg[key]))
|
|
return seeded or None
|
|
|
|
|
|
# --------------------------------------------------------------------------- env → extra seeding
|
|
# (env_enablement_fn)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def seed_extra_from_env(spec: Iterable[tuple[str, str, Callable[[str], Any] | None]], *,
|
|
home_env: str | None = None, home_default: str = "") -> dict:
|
|
"""Table-driven ``env_enablement_fn`` body: for each ``(ENV_VAR, extra_key, conv)`` row read the
|
|
profile-scoped value, skip it when blank or when ``conv`` raises ``ValueError``, else seed
|
|
``conv(value)`` (``None`` conv keeps the stripped string).
|
|
|
|
``home_env`` seeds ``home_channel`` as ``{"chat_id", "name"}`` from ``<home_env>`` (else
|
|
``home_default``) with ``<home_env>_NAME`` naming it — the core hook lifts that dict into a
|
|
``HomeChannel``. The name falls back to the literal ``"Home"``, the same rule the built-in
|
|
platforms use.
|
|
"""
|
|
seed: dict = {}
|
|
for env, key, conv in spec:
|
|
raw = str(get_scoped_secret(env, "") or "").strip()
|
|
if not raw:
|
|
continue
|
|
with contextlib.suppress(ValueError):
|
|
seed[key] = conv(raw) if conv else raw
|
|
if home_env:
|
|
home = str(get_scoped_secret(home_env, "") or "").strip() or home_default
|
|
if home:
|
|
seed["home_channel"] = {"chat_id": home, "name": get_scoped_secret(f"{home_env}_NAME", "Home")}
|
|
return seed
|
|
|
|
|
|
def env_is_connected(*names: str) -> Callable[[Any], bool]:
|
|
"""``is_connected`` for platforms whose only configuration is env: True when every ``names`` var is
|
|
non-blank. Resolves ``hermes_cli.gateway.get_env_value`` at call time (scope-aware, .env-backed) so
|
|
setup-status tests that patch it see the same value."""
|
|
|
|
def is_connected(config: Any) -> bool:
|
|
import hermes_cli.gateway as gateway_mod
|
|
return all((gateway_mod.get_env_value(name) or "").strip() for name in names)
|
|
|
|
return is_connected
|
|
|
|
|
|
def coerce_port(value: Any, default: int) -> int:
|
|
"""``int(value)`` or ``default`` when unparseable."""
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|