Files
hermes-agent/tests/gateway/test_adapter_settings_profile_parity.py
teknium1 de114b3af1 refactor(platforms): one scoped-secret reader and spec-driven enablement/YAML-bridge boilerplate across all adapters
Scoped secrets — `gateway.platforms._shared.get_scoped_secret` is the single implementation of
the "scope authoritative, unscoped default-profile falls back to os.environ" read:

- plugins/platforms/buzz/adapter.py::_get_scoped_secret (113 LOC, ~100 of which were one
  docstring paragraph pasted 16x) -> 3-line forwarder over the canonical with
  `external_fallback=True`. Its one genuine extra rung (one-shot profile-scope build so a
  Bitwarden-managed key is visible to the startup gate, #95216) moves into `_shared` as that
  keyword plus `_unscoped_profile_secrets`.
- weixin::_wx_secret, matrix::_startup_env_secret, the inline try/except copies in slack
  (SLACK_APP_TOKEN) and telegram (TELEGRAM_WEBHOOK_SECRET/_URL) -> canonical.
- The "extra-first, then scoped env" reader written 11x under 6 names (weixin._extra_or_env,
  bluebubbles/ntfy/photon/wecom `_setting`, dingtalk `_extra_get`, mattermost `_extra_or_env`,
  slack `_extra_or_env_flag/_channel_set`, feishu closures) -> `_shared.extra_or_secret`.
- `authz_mixin._platform_gate_env` -> `_shared.platform_gate_env`; discord/telegram drop their
  `_scoped_gate_env` twins; run.py / run_config_loaders.py / slack import it directly.

Boilerplate — three table-driven helpers in `_shared` replace the pasted docs template:

- `seed_extra_from_env(spec, home_env=)` replaces 8 `_env_enablement` bodies (buzz, google_chat,
  irc, line, ntfy, photon, simplex, teams; raft is a one-liner and untouched).
- `apply_yaml_bridge(cfg, spec)` replaces 7 `_apply_yaml_config` bodies (buzz, dingtalk, feishu,
  matrix, mattermost, slack, whatsapp); discord/telegram keep bespoke bridges (alias keys,
  nested `platforms.*.extra`, generic-key exclusions). buzz and mattermost previously bypassed
  `yaml_env_setter` with hand-rolled `os.environ` writes.
- `env_is_connected(*vars)` replaces 5 identical `_is_connected` (discord, homeassistant,
  mattermost, slack, sms).
- 8 identity `_build_adapter` wrappers deleted; `adapter_factory=<Class>`.

Behavior change:
- buzz `_apply_yaml_config` returned None, so under multiplex a secondary Buzz profile got
  neither env (correctly skipped) nor `extra` for relay_url/channels/allow_all_users/...; it now
  seeds `extra` like every other hook. It also wrote reply_in_thread/reply_to_mode to the process
  env even inside a secondary profile's scope (first-writer-wins leak, #80099 class); it no longer
  does. BUZZ_POLL_INTERVAL is bridged through the same table.
- `home_channel.name` default when `<X>_HOME_CHANNEL_NAME` is unset is now the literal "Home" for
  all plugins (irc/ntfy/buzz used the chat id; simplex/teams/photon/google_chat already used
  "Home", as do the built-in platforms in gateway/config_env.py).
- weixin's non-secret tunables (send_chunk_*, rate_limit_circuit_*) now read through the scoped
  reader instead of raw os.getenv — a secondary profile no longer inherits the default's values.
- `extra_or_secret` treats a blank string in extra as unset (falls to env) and an explicit False
  as a real value, the strictest of the merged copies.
- slack `reaction_trigger_target` bridges via str(); `reaction_triggers` comma-joins any list-ish
  value (was list/tuple/set only) — same env text for every real YAML shape.

Docs: website/docs/developer-guide/adding-platform-adapters.md (the template the copies were
pasted from) and gateway/platforms/ADDING_A_PLATFORM.md now show the helpers and the scoped
reader; gateway/AGENTS.md points at the one implementation.

Tests: tests/gateway/test_shared_platform_boilerplate.py — every plugin `_env_enablement`
reads only through the scoped getter (parametrized over the 8 plugins, spy on the seam, raw
`os.getenv`/`get_env_value` asserted untouched); buzz bridge seeds `extra` for a secondary
profile and still bridges env for the default; one home-name rule; extra_or_secret contract;
external_fallback rung. Existing tests repointed: tests/agent/test_secret_scope_tier1_migration.py,
tests/plugins/platforms/buzz/test_buzz_unscoped_requirement_gate.py.
2026-09-13 05:32:38 -07:00

157 lines
8.0 KiB
Python

"""Standalone-vs-served parity for adapter SETTINGS (not credentials) under ``gateway.multiplex_profiles``.
A served secondary profile's adapter is constructed inside ``_profile_runtime_scope`` while ``os.environ``
still holds the DEFAULT profile's ``.env``. Every non-credential setting an adapter reads with a bare
``os.getenv`` (ports, hosts, mention gating, reactions, thread policy, notification targets) therefore
resolves to the default profile's value — the adapter behaves differently served than it does standalone.
The invariant: with the SAME profile ``.env`` the adapter resolves the SAME setting whether the profile is
the ambient process home (standalone) or a scoped secondary (served). Each row names the setting and the
callable that resolves it from a constructed adapter, so a regression is reported by setting name.
"""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from agent import secret_scope as ss
from gateway.config import PlatformConfig
@pytest.fixture(autouse=True)
def _multiplex_off_after():
ss.set_multiplex_active(False)
yield
ss.set_multiplex_active(False)
def _served(monkeypatch, default_env: dict, secondary_env: dict, build):
"""``(standalone_value, served_value)`` for one setting: standalone = the secondary's env IS the
process env; served = default's env in the process, secondary's only in the scope."""
for k, v in secondary_env.items():
monkeypatch.setenv(k, v)
standalone = build()
for k in secondary_env:
monkeypatch.delenv(k, raising=False)
for k, v in default_env.items():
monkeypatch.setenv(k, v)
ss.set_multiplex_active(True)
token = ss.set_secret_scope(dict(secondary_env))
try:
served = build()
finally:
ss.reset_secret_scope(token)
return standalone, served
# (module, env var, default-profile value, secondary value, resolver(module) -> value)
_SETTINGS = [
("plugins.platforms.slack.adapter", "SLACK_REQUIRE_MENTION", "true", "false",
lambda m: _slack(m, "_slack_require_mention")),
("plugins.platforms.slack.adapter", "SLACK_REACTIONS", "true", "false",
lambda m: _slack(m, "_reactions_enabled")),
("plugins.platforms.slack.adapter", "SLACK_REACTION_TRIGGER_TARGET", "C_DEFAULT", "C_SECONDARY",
lambda m: _slack(m, "_slack_reaction_trigger_target")),
("plugins.platforms.matrix.adapter", "MATRIX_REQUIRE_MENTION", "true", "false",
lambda m: m.MatrixAdapter._parse_require_mention(PlatformConfig(enabled=True))),
("plugins.platforms.matrix.adapter", "MATRIX_THREAD_REQUIRE_MENTION", "false", "true",
lambda m: m.MatrixAdapter._parse_thread_require_mention(PlatformConfig(enabled=True))),
("plugins.platforms.matrix.adapter", "MATRIX_MAX_MESSAGE_LENGTH", "1111", "2222",
lambda m: m._resolve_max_message_length(PlatformConfig(enabled=True))),
("plugins.platforms.matrix.adapter", "MATRIX_E2EE_MODE", "required", "off",
lambda m: m._resolve_e2ee_mode({})),
("plugins.platforms.matrix.adapter", "MATRIX_ALLOW_PUBLIC_ROOMS", "true", "false",
lambda m: m._env_truthy("MATRIX_ALLOW_PUBLIC_ROOMS")),
("plugins.platforms.teams.adapter", "TEAMS_PORT", "18001", "18101",
lambda m: (m._env_enablement() or {}).get("port")),
("plugins.platforms.feishu.adapter", "FEISHU_WEBHOOK_PORT", "18073", "18173",
lambda m: m.FeishuAdapter._load_settings({}).webhook_port),
("plugins.platforms.feishu.adapter", "FEISHU_CONNECTION_MODE", "websocket", "webhook",
lambda m: m.FeishuAdapter._load_settings({}).connection_mode),
("gateway.platforms.bluebubbles", "BLUEBUBBLES_WEBHOOK_PORT", "18010", "18110",
lambda m: m._extra_or_secret({}, "webhook_port", "BLUEBUBBLES_WEBHOOK_PORT", "0")),
("gateway.platforms.signal", "SIGNAL_REACTIONS", "true", "false",
lambda m: _signal_reactions(m)),
("plugins.platforms.line.adapter", "LINE_PORT", "18015", "18115",
lambda m: (m._env_enablement() or {}).get("port")),
("plugins.platforms.buzz.adapter", "BUZZ_RELAY_URL", "wss://default.relay", "wss://secondary.relay",
lambda m: (m._env_enablement() or {}).get("relay_url")),
("plugins.platforms.a2a.adapter", "A2A_AGENT_NAME", "default-agent", "secondary-agent",
lambda m: m._default_agent_name()),
("plugins.platforms.discord.adapter", "DISCORD_COMMAND_SYNC_POLICY", "full", "off",
lambda m: _discord(m, "_get_discord_command_sync_policy")),
("plugins.platforms.discord.adapter", "DISCORD_ALLOW_MENTION_EVERYONE", "true", "false",
lambda m: m._env_bool("DISCORD_ALLOW_MENTION_EVERYONE", False)),
]
def _slack(mod, method):
adapter = object.__new__(mod.SlackAdapter)
adapter.config = PlatformConfig(enabled=True)
return getattr(adapter, method)()
def _signal_reactions(mod):
adapter = object.__new__(mod.SignalAdapter)
adapter.dm_allow_from = {"*"}
return adapter._reactions_enabled()
def _discord(mod, method):
adapter = object.__new__(mod.DiscordAdapter)
adapter.config = PlatformConfig(enabled=True)
adapter.platform = adapter.config and __import__("gateway.config", fromlist=["Platform"]).Platform.DISCORD
return getattr(adapter, method)()
@pytest.mark.parametrize(("module", "var", "default_value", "secondary_value", "resolve"), _SETTINGS,
ids=[f"{m.rsplit('.', 2)[-2]}:{v}" for m, v, *_ in _SETTINGS])
def test_served_secondary_resolves_its_own_setting(monkeypatch, module, var, default_value, secondary_value, resolve):
mod = importlib.import_module(module)
for name in (var, "LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "TEAMS_CLIENT_ID", "TEAMS_CLIENT_SECRET",
"TEAMS_TENANT_ID", "SIGNAL_ACCOUNT", "BUZZ_PRIVATE_KEY"):
monkeypatch.delenv(name, raising=False)
creds = {"LINE_CHANNEL_ACCESS_TOKEN": "t", "LINE_CHANNEL_SECRET": "s", "TEAMS_CLIENT_ID": "a",
"TEAMS_CLIENT_SECRET": "b", "TEAMS_TENANT_ID": "c", "SIGNAL_ACCOUNT": "+1", "BUZZ_PRIVATE_KEY": "k"}
standalone, served = _served(monkeypatch, {var: default_value, **creds}, {var: secondary_value, **creds},
lambda: resolve(mod))
assert served == standalone, f"{var}: served={served!r} standalone={standalone!r}"
def test_signal_startup_gate_reads_the_profile_env(monkeypatch):
"""A secondary whose SIGNAL_HTTP_URL/SIGNAL_ACCOUNT live only in its own .env must pass the startup
gate served exactly as it does standalone; a secondary WITHOUT them must not borrow the default's."""
from gateway.platforms import signal as sig
for k in ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"):
monkeypatch.delenv(k, raising=False)
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"SIGNAL_HTTP_URL": "http://secondary.invalid:8080", "SIGNAL_ACCOUNT": "+15550000001"})
try:
assert sig.validate_signal_config(PlatformConfig(enabled=True)) is True
finally:
ss.reset_secret_scope(token)
monkeypatch.setenv("SIGNAL_HTTP_URL", "http://default.invalid:8080")
monkeypatch.setenv("SIGNAL_ACCOUNT", "+15550000000")
token = ss.set_secret_scope({})
try:
assert sig.validate_signal_config(PlatformConfig(enabled=True)) is False
finally:
ss.reset_secret_scope(token)
def test_sms_webhook_listener_is_profile_scoped(monkeypatch):
from plugins.platforms.sms import adapter as sms
for k, v in {"TWILIO_ACCOUNT_SID": "AC0", "TWILIO_AUTH_TOKEN": "tok", "TWILIO_PHONE_NUMBER": "+1",
"SMS_WEBHOOK_PORT": "18039", "SMS_WEBHOOK_HOST": "default-host", "SMS_WEBHOOK_URL": "http://d/"}.items():
monkeypatch.setenv(k, v)
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"TWILIO_ACCOUNT_SID": "AC1", "TWILIO_AUTH_TOKEN": "tok2", "TWILIO_PHONE_NUMBER": "+2",
"SMS_WEBHOOK_PORT": "18139", "SMS_WEBHOOK_HOST": "secondary-host", "SMS_WEBHOOK_URL": "http://s/"})
try:
adapter = sms.SmsAdapter(PlatformConfig(enabled=True))
finally:
ss.reset_secret_scope(token)
assert (adapter._webhook_port, adapter._webhook_host, adapter._webhook_url) == (18139, "secondary-host", "http://s/")