hermes-agent/tests/gateway/test_config.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

1186 lines
45 KiB
Python

"""Tests for gateway configuration management."""
import logging
import os
from pathlib import Path
from unittest.mock import patch
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
from gateway.config import (
ChannelOverride,
GatewayConfig,
HomeChannel,
Platform,
PlatformConfig,
SessionResetPolicy,
StreamingConfig,
_apply_env_overrides,
load_gateway_config,
persist_home_channel,
)
class TestHomeChannelRoundtrip:
def test_to_dict_from_dict(self):
hc = HomeChannel(
platform=Platform.DISCORD,
chat_id="999",
name="general",
user_id="user-123",
scope_id="guild-456",
)
d = hc.to_dict()
restored = HomeChannel.from_dict(d)
assert restored.platform == Platform.DISCORD
assert restored.chat_id == "999"
assert restored.name == "general"
assert restored.user_id == "user-123"
assert restored.scope_id == "guild-456"
class TestPlatformConfigRoundtrip:
def test_to_dict_from_dict(self):
pc = PlatformConfig(
enabled=True,
token="tok_123",
home_channel=HomeChannel(
platform=Platform.TELEGRAM,
chat_id="555",
name="Home",
),
extra={"foo": "bar"},
)
d = pc.to_dict()
restored = PlatformConfig.from_dict(d)
assert restored.enabled is True
assert restored.token == "tok_123"
assert restored.home_channel.chat_id == "555"
assert restored.extra == {"foo": "bar"}
def test_disabled_no_token(self):
pc = PlatformConfig()
d = pc.to_dict()
restored = PlatformConfig.from_dict(d)
assert restored.enabled is False
assert restored.token is None
def test_from_dict_coerces_quoted_false_enabled(self):
restored = PlatformConfig.from_dict({"enabled": "false"})
assert restored.enabled is False
def test_gateway_restart_notification_roundtrip_false(self):
pc = PlatformConfig(enabled=True, gateway_restart_notification=False)
restored = PlatformConfig.from_dict(pc.to_dict())
assert restored.gateway_restart_notification is False
def test_typing_status_text_resolved_from_extra(self):
# Same bridge route as typing_indicator: the shared-key loop copies a
# nested platforms.<plat> value into extra.
restored = PlatformConfig.from_dict(
{"extra": {"typing_status_text": "chasing yarn…"}}
)
assert restored.typing_status_text == "chasing yarn…"
def test_channel_overrides_roundtrip(self):
pc = PlatformConfig(
enabled=True,
channel_overrides={
"1234567890": ChannelOverride(
model="openrouter/healer-alpha",
provider="openrouter",
system_prompt="You are a daily news summarizer.",
),
"9876543210": ChannelOverride(
model="anthropic/claude-opus-4.6",
provider="anthropic",
system_prompt="You are a coding assistant.",
),
},
)
d = pc.to_dict()
assert "channel_overrides" in d
assert d["channel_overrides"]["1234567890"]["model"] == "openrouter/healer-alpha"
assert d["channel_overrides"]["9876543210"]["system_prompt"] == "You are a coding assistant."
restored = PlatformConfig.from_dict(d)
assert restored.channel_overrides["1234567890"].model == "openrouter/healer-alpha"
assert restored.channel_overrides["9876543210"].provider == "anthropic"
class TestChannelOverride:
def test_from_dict_empty(self):
assert ChannelOverride.from_dict({}).model is None
assert ChannelOverride.from_dict(None).model is None
class TestPlatformConfigMalformedSections:
def test_from_dict_ignores_malformed_nested_sections(self):
restored = PlatformConfig.from_dict(
{
"enabled": True,
"home_channel": "telegram:123",
"extra": "oops",
}
)
assert restored.enabled is True
assert restored.home_channel is None
assert restored.extra == {}
class TestGetConnectedPlatforms:
def test_returns_enabled_with_token(self):
config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token="t"),
Platform.DISCORD: PlatformConfig(enabled=False, token="d"),
Platform.SLACK: PlatformConfig(enabled=True), # no token
},
)
connected = config.get_connected_platforms()
assert Platform.TELEGRAM in connected
assert Platform.DISCORD not in connected
assert Platform.SLACK not in connected
def test_dingtalk_recognised_via_env_vars(self, monkeypatch):
"""DingTalk configured via env vars (no extras) should still be
recognised as connected — covers the case where _apply_env_overrides
hasn't populated extras yet."""
monkeypatch.setenv("DINGTALK_CLIENT_ID", "env_cid")
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env_sec")
config = GatewayConfig(
platforms={
Platform.DINGTALK: PlatformConfig(enabled=True, extra={}),
},
)
assert Platform.DINGTALK in config.get_connected_platforms()
class TestSessionResetPolicy:
def test_roundtrip(self):
policy = SessionResetPolicy(mode="idle", at_hour=6, idle_minutes=120,
bg_process_max_age_hours=48)
d = policy.to_dict()
restored = SessionResetPolicy.from_dict(d)
assert restored.mode == "idle"
assert restored.at_hour == 6
assert restored.idle_minutes == 120
assert restored.bg_process_max_age_hours == 48
def test_from_dict_treats_null_values_as_defaults(self):
restored = SessionResetPolicy.from_dict(
{"mode": None, "at_hour": None, "idle_minutes": None,
"bg_process_max_age_hours": None}
)
assert restored.mode == "none"
assert restored.at_hour == 4
assert restored.idle_minutes == 1440
assert restored.bg_process_max_age_hours == 24
class TestStreamingConfig:
def test_from_dict_malformed_numeric_values_fall_back_to_defaults(self):
restored = StreamingConfig.from_dict(
{
"edit_interval": "oops",
"buffer_threshold": "oops",
"fresh_final_after_seconds": "oops",
}
)
assert restored.edit_interval == 0.8
assert restored.buffer_threshold == 24
assert restored.fresh_final_after_seconds == 0.0
class TestGatewayConfigRoundtrip:
def test_systemd_watchdog_from_dict_disables_invalid_values(self):
invalid_values = [
None,
0,
-1,
True,
1.5,
float("nan"),
float("inf"),
"120.0",
"1e3",
"bad",
2_147_483_648,
]
for raw in invalid_values:
config = GatewayConfig.from_dict({"systemd_watchdog_seconds": raw})
assert config.systemd_watchdog_seconds == 0
def test_max_concurrent_sessions_from_dict_ignores_invalid_values(self, caplog):
caplog.set_level(logging.WARNING, logger="gateway.config")
config = GatewayConfig.from_dict({"max_concurrent_sessions": "many"})
assert config.max_concurrent_sessions is None
assert any(
"Ignoring invalid max_concurrent_sessions='many'" in record.message
for record in caplog.records
)
def test_roundtrip_preserves_unauthorized_dm_behavior(self):
config = GatewayConfig(
unauthorized_dm_behavior="ignore",
platforms={
Platform.WHATSAPP: PlatformConfig(
enabled=True,
extra={"unauthorized_dm_behavior": "pair"},
),
},
)
restored = GatewayConfig.from_dict(config.to_dict())
assert restored.unauthorized_dm_behavior == "ignore"
assert restored.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair"
def test_email_defaults_to_ignore_for_unauthorized_dm_behavior(self):
config = GatewayConfig(
platforms={Platform.EMAIL: PlatformConfig(enabled=True)},
)
assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "ignore"
def test_email_can_opt_into_pairing_for_unauthorized_dm_behavior(self):
config = GatewayConfig(
platforms={
Platform.EMAIL: PlatformConfig(
enabled=True,
extra={"unauthorized_dm_behavior": "pair"},
),
},
)
assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "pair"
class TestLoadGatewayConfig:
def test_shipped_template_does_not_enable_auto_reset(self, tmp_path, monkeypatch):
"""A fresh install seeded from cli-config.yaml.example must not
auto-reset sessions.
Installers (scripts/install.sh, scripts/install.ps1,
docker/stage2-hook.sh, hermes doctor) copy the template verbatim to
~/.hermes/config.yaml, so whatever ``session_reset.mode`` the template
ships becomes an EXPLICIT user setting that overrides the code
default. After #60194 flipped the default to "none", the template
still said "both" — every new install kept 24h-idle resets on
(Luciano's report, July 2026). This pins the invariant: template
seed == no auto-reset.
"""
template = (
Path(__file__).resolve().parents[2] / "cli-config.yaml.example"
)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
template.read_text(encoding="utf-8"), encoding="utf-8"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.default_reset_policy.mode == "none"
def test_no_config_yaml_means_no_auto_reset(self, tmp_path, monkeypatch):
"""With no config.yaml at all, sessions must never auto-reset."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.default_reset_policy.mode == "none"
def test_explicit_session_reset_opt_in_is_honored(self, tmp_path, monkeypatch):
"""Users who explicitly opt in to auto-reset keep their policy."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"session_reset:\n mode: idle\n idle_minutes: 30\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.default_reset_policy.mode == "idle"
assert config.default_reset_policy.idle_minutes == 30
def test_slack_ignored_channels_config_sets_env_bridge(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"slack:\n"
" ignored_channels:\n"
" - C0123456789\n"
" - C0987654321\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SLACK_IGNORED_CHANNELS", raising=False)
load_gateway_config()
assert os.getenv("SLACK_IGNORED_CHANNELS") == "C0123456789,C0987654321"
def test_typing_status_text_from_nested_platforms_block(self, tmp_path, monkeypatch):
"""``platforms.slack.typing_status_text`` reaches PlatformConfig via
_merge_platform_map + the from_dict top-level read."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"platforms:\n"
" slack:\n"
" enabled: true\n"
' typing_status_text: "chasing yarn…"\n',
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert (
config.platforms[Platform.SLACK].typing_status_text == "chasing yarn…"
)
def test_multiplex_profiles_from_nested_gateway_section(self, tmp_path, monkeypatch):
"""``gateway.multiplex_profiles: true`` (the nested form written by
``hermes config set gateway.multiplex_profiles true``) must enable
multiplexing when loaded via load_gateway_config().
Regression: load_gateway_config() only surfaced the *top-level*
``multiplex_profiles`` key into gw_data, so a config.yaml that pinned
the flag under the nested ``gateway:`` section silently loaded with
multiplex_profiles=False. (from_dict honors the nested fallback, but
load_gateway_config builds gw_data from the top-level keys before
calling from_dict, so the nested value never reached it.)
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n multiplex_profiles: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is True
def test_discord_websocket_health_settings_seed_platform_extra(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"discord:\n"
" websocket_liveness_interval_seconds: 17\n"
" websocket_liveness_failure_threshold: 4\n"
" websocket_heartbeat_ack_max_age_seconds: 75\n"
" websocket_max_latency_seconds: 30\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
for key in (
"HERMES_DISCORD_LIVENESS_INTERVAL_SECONDS",
"HERMES_DISCORD_LIVENESS_FAILURE_THRESHOLD",
):
monkeypatch.delenv(key, raising=False)
config = load_gateway_config()
extra = config.platforms[Platform.DISCORD].extra
assert extra["websocket_liveness_interval_seconds"] == 17
assert extra["websocket_liveness_failure_threshold"] == 4
assert extra["websocket_heartbeat_ack_max_age_seconds"] == 75
assert extra["websocket_max_latency_seconds"] == 30
def test_session_reset_from_nested_gateway_section(self, tmp_path, monkeypatch):
"""``gateway.session_reset`` (nested form) must reach default_reset_policy,
mirroring the gateway.multiplex_profiles precedent."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n session_reset:\n mode: idle\n idle_minutes: 30\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.default_reset_policy.mode == "idle"
assert config.default_reset_policy.idle_minutes == 30
def test_quick_commands_from_nested_gateway_section(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n quick_commands:\n limits:\n type: exec\n command: echo ok\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
def test_stt_from_nested_gateway_section(self, tmp_path, monkeypatch):
"""Asserts False (not the True default) so the test fails if the
nested gateway.stt value never reaches from_dict() and silently
falls back to the class default instead."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n stt:\n enabled: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.stt_enabled is False
@staticmethod
def _clear_api_server_env(monkeypatch):
"""Keep _apply_env_overrides from masking the YAML path under test."""
for key in (
"API_SERVER_ENABLED",
"API_SERVER_KEY",
"API_SERVER_PORT",
"API_SERVER_HOST",
"API_SERVER_CORS_ORIGINS",
"API_SERVER_MODEL_NAME",
):
monkeypatch.delenv(key, raising=False)
def test_api_server_from_nested_gateway_section(self, tmp_path, monkeypatch):
"""``gateway.api_server:`` (nested YAML form) must be discovered and
enable the platform.
Regression for #66630: load_gateway_config handled gateway.streaming
and gateway.platforms.* but silently dropped nested gateway.<platform>
blocks, so ``gateway.api_server.enabled: true`` never started the API
server unless API_SERVER_* env vars were also set.
"""
self._clear_api_server_env(monkeypatch)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n api_server:\n enabled: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert Platform.API_SERVER in config.platforms
assert config.platforms[Platform.API_SERVER].enabled is True
def test_api_server_port_bridged_into_extra(self, tmp_path, monkeypatch):
"""``gateway.api_server.port`` must land in PlatformConfig.extra —
the adapter reads port/key/host/cors_origins/model_name from extra
(gateway/platforms/api_server.py), and from_dict discards unknown
top-level keys, so without the bridge the port is silently lost."""
self._clear_api_server_env(monkeypatch)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n"
" api_server:\n"
" enabled: true\n"
" port: 8642\n"
" host: 0.0.0.0\n"
" key: sekrit\n"
" model_name: my-hermes\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
extra = config.platforms[Platform.API_SERVER].extra
assert extra["port"] == 8642
assert extra["host"] == "0.0.0.0"
assert extra["key"] == "sekrit"
assert extra["model_name"] == "my-hermes"
def test_non_platform_gateway_keys_not_misparsed_as_platforms(self, tmp_path, monkeypatch):
"""Nested-platform discovery must only pick up keys matching the
Platform enum: ``gateway.streaming`` / ``gateway.timeout`` must not
be turned into phantom platform entries or break loading."""
self._clear_api_server_env(monkeypatch)
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n"
" streaming:\n"
" enabled: false\n"
" timeout: 120\n"
" api_server:\n"
" enabled: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
# streaming still parsed as the streaming subsection, not a platform
assert config.streaming.enabled is False
# only real platforms present; nothing named streaming/timeout leaked in
assert all(isinstance(p, Platform) for p in config.platforms)
assert config.platforms[Platform.API_SERVER].enabled is True
def test_group_sessions_per_user_from_nested_gateway_section(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n group_sessions_per_user: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.group_sessions_per_user is False
def test_reset_triggers_from_nested_gateway_section(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n reset_triggers:\n - /new\n - /clear\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.reset_triggers == ["/new", "/clear"]
def test_always_log_local_from_nested_gateway_section(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n always_log_local: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.always_log_local is False
def test_unauthorized_dm_behavior_from_nested_gateway_section(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n unauthorized_dm_behavior: ignore\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.unauthorized_dm_behavior == "ignore"
def test_present_empty_top_level_session_reset_blocks_nested_fallback(self, tmp_path, monkeypatch):
"""Key-presence precedence: a present (even empty) top-level
session_reset must NOT be replaced by gateway.session_reset —
the fallback fires only when the top-level key is absent."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"session_reset: {}\n"
"gateway:\n"
" session_reset:\n"
" mode: idle\n"
" idle_minutes: 30\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
# The nested value must not leak through the present top-level key.
assert config.default_reset_policy.mode != "idle"
def test_relay_platform_enabled_from_env_url(self, tmp_path, monkeypatch):
"""GATEWAY_RELAY_URL must enable Platform.RELAY in config.platforms so
start_gateway()'s connect loop actually dials the connector. Registering
the adapter in the platform_registry is NOT enough — the connect loop
iterates config.platforms, so an un-enabled RELAY never connects (the
'relay registered but no inbound' bug)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("GATEWAY_RELAY_URL", "https://connector.example/relay/")
config = load_gateway_config()
assert Platform.RELAY in config.platforms
relay = config.platforms[Platform.RELAY]
assert relay.enabled is True
# Trailing slash stripped; mirrored into extra for the connected-checker.
assert relay.extra.get("relay_url") == "https://connector.example/relay"
assert Platform.RELAY in config.get_connected_platforms()
def test_thread_require_mention_yaml_does_not_overwrite_env(self, tmp_path, monkeypatch):
"""Explicit env var should win over config.yaml (env > yaml precedence)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"discord:\n"
" thread_require_mention: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") # user override
load_gateway_config()
# Env value preserved, not clobbered by yaml.
assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true"
def test_bridges_nested_gateway_platforms_dingtalk_allowed_users_to_env(self, tmp_path, monkeypatch):
"""gateway.platforms.dingtalk.extra.allowed_users must reach
DINGTALK_ALLOWED_USERS — it's the documented config.yaml alternative
to the env var (website/docs/user-guide/messaging/dingtalk.md), the
adapter reads it from PlatformConfig.extra, but gateway auth
(_is_user_authorized) only consults the env var.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n"
" platforms:\n"
" dingtalk:\n"
" enabled: true\n"
" extra:\n"
" allowed_users:\n"
" - user-id-1\n"
" - user-id-2\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("DINGTALK_ALLOWED_USERS", raising=False)
config = load_gateway_config()
assert config.platforms[Platform.DINGTALK].extra["allowed_users"] == [
"user-id-1",
"user-id-2",
]
assert os.environ.get("DINGTALK_ALLOWED_USERS") == "user-id-1,user-id-2"
def test_top_level_platforms_override_nested_gateway_platforms(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n"
" platforms:\n"
" telegram:\n"
" enabled: false\n"
" token: nested-token\n"
" extra:\n"
" reply_prefix: nested\n"
"platforms:\n"
" telegram:\n"
" enabled: true\n"
" token: top-token\n"
" extra:\n"
" reply_prefix: top\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
telegram = config.platforms[Platform.TELEGRAM]
assert telegram.enabled is True
assert telegram.token == "top-token"
assert telegram.extra["reply_prefix"] == "top"
def test_shared_key_loop_bridges_allow_from_from_nested_platforms(self, tmp_path, monkeypatch):
"""Regression: shared-key loop must bridge allow_from / require_mention
into PlatformConfig.extra even when the platform is configured only
under ``platforms:`` (no top-level ``telegram:`` block).
Before the fix, ``platform_cfg = yaml_cfg.get('telegram')`` returned
None for nested-only configs, so the loop skipped the platform entirely
and allow_from was silently ignored. The apply_yaml_config_fn dispatch
received the same fix in #44f3e51; the shared-key loop now mirrors it.
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"platforms:\n"
" telegram:\n"
" allow_from:\n"
" - \"111222333\"\n"
" - \"444555666\"\n"
" require_mention: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
telegram = config.platforms[Platform.TELEGRAM]
assert telegram.extra.get("allow_from") == ["111222333", "444555666"], (
"allow_from configured under platforms.telegram must be bridged "
"into PlatformConfig.extra by the shared-key loop"
)
assert telegram.extra.get("require_mention") is True, (
"require_mention configured under platforms.telegram must be "
"bridged into PlatformConfig.extra by the shared-key loop"
)
def test_bridges_unauthorized_dm_behavior_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"unauthorized_dm_behavior: ignore\n"
"whatsapp:\n"
" unauthorized_dm_behavior: pair\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.unauthorized_dm_behavior == "ignore"
assert config.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair"
def test_loads_telegram_rich_messages_from_gateway_platform_extra(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"gateway:\n"
" platforms:\n"
" telegram:\n"
" extra:\n"
" rich_messages: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False
def test_telegram_proxy_env_takes_precedence_over_config(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"telegram:\n"
" proxy_url: http://from-config:8080\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("TELEGRAM_PROXY", "socks5://from-env:1080")
load_gateway_config()
import os
assert os.environ.get("TELEGRAM_PROXY") == "socks5://from-env:1080"
def test_profile_scoped_env_overrides_do_not_fall_back_to_default_profile_env(
self,
tmp_path,
monkeypatch,
):
default_home = tmp_path / "default-home"
default_home.mkdir()
default_config = default_home / "config.yaml"
default_config.write_text(
"multiplex_profiles: true\n",
encoding="utf-8",
)
secondary_home = tmp_path / "secondary-home"
secondary_home.mkdir()
secondary_config = secondary_home / "config.yaml"
secondary_config.write_text(
"multiplex_profiles: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(default_home))
monkeypatch.setenv("API_SERVER_ENABLED", "true")
monkeypatch.setenv("DISCORD_BOT_TOKEN", "default-token")
# Model the real multiplexed gateway: run.py flips the runtime flag at
# startup (set_multiplex_active) BEFORE any secondary-profile config
# loads. Without the flag, a scope miss legitimately falls through to
# os.environ (single-profile overlay semantics, #67827) and this test
# would no longer exercise the cross-profile isolation it's about.
set_multiplex_active(True)
home_token = set_hermes_home_override(str(secondary_home))
secret_token = set_secret_scope({"DISCORD_BOT_TOKEN": "worker-token"})
try:
config = load_gateway_config()
finally:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)
set_multiplex_active(False)
assert config.multiplex_profiles is True
assert config.platforms[Platform.DISCORD].token == "worker-token"
assert Platform.API_SERVER not in config.platforms
class TestWebhookPortBridging:
"""Top-level port/host in the YAML platform section must be bridged into
PlatformConfig.extra so the webhook/api_server adapters can read them.
The adapters (WebhookAdapter, ApiServerAdapter) read port/host from
``config.extra``, but users naturally write them at the top level of the
platform section in config.yaml:
platforms:
webhook:
enabled: true
host: 0.0.0.0
port: 8649
Without bridging, the port silently falls back to DEFAULT_PORT (8644),
causing port conflicts between profiles that configure different ports."""
def test_webhook_port_bridged_from_toplevel(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"platforms:\n"
" webhook:\n"
" enabled: true\n"
" host: 0.0.0.0\n"
" port: 8649\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("WEBHOOK_ENABLED", raising=False)
monkeypatch.delenv("WEBHOOK_PORT", raising=False)
config = load_gateway_config()
assert Platform.WEBHOOK in config.platforms
wh = config.platforms[Platform.WEBHOOK]
assert wh.enabled is True
assert wh.extra.get("port") == 8649
assert wh.extra.get("host") == "0.0.0.0"
def test_msgraph_webhook_port_host_secret_bridged_from_toplevel(self, tmp_path, monkeypatch):
"""msgraph_webhook top-level port/host/secret must be bridged into extra,
with an explicit extra: value still winning over the top-level one."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"platforms:\n"
" msgraph_webhook:\n"
" enabled: true\n"
" host: 0.0.0.0\n"
" port: 8651\n"
" secret: toplevel-secret\n"
" extra:\n"
" client_state: my-client-state\n"
" secret: extra-secret\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("MSGRAPH_WEBHOOK_ENABLED", raising=False)
monkeypatch.delenv("MSGRAPH_WEBHOOK_PORT", raising=False)
monkeypatch.delenv("MSGRAPH_WEBHOOK_CLIENT_STATE", raising=False)
config = load_gateway_config()
assert Platform.MSGRAPH_WEBHOOK in config.platforms
ms = config.platforms[Platform.MSGRAPH_WEBHOOK]
assert ms.enabled is True
assert ms.extra.get("port") == 8651
assert ms.extra.get("host") == "0.0.0.0"
# explicit extra: wins over top-level
assert ms.extra.get("secret") == "extra-secret"
assert ms.extra.get("client_state") == "my-client-state"
class TestHomeChannelEnvOverrides:
"""Home channel env vars should apply even when the platform was already
configured via config.yaml (not just when credential env vars create it)."""
def test_existing_platform_configs_accept_home_channel_env_overrides(self):
cases = [
(
Platform.SLACK,
PlatformConfig(enabled=True, token="xoxb-from-config"),
{"SLACK_HOME_CHANNEL": "C123", "SLACK_HOME_CHANNEL_NAME": "Ops"},
("C123", "Ops"),
),
(
Platform.WHATSAPP,
PlatformConfig(enabled=True),
{
"WHATSAPP_HOME_CHANNEL": "1234567890@lid",
"WHATSAPP_HOME_CHANNEL_NAME": "Owner DM",
},
("1234567890@lid", "Owner DM"),
),
(
Platform.SIGNAL,
PlatformConfig(
enabled=True,
extra={"http_url": "http://localhost:9090", "account": "+15551234567"},
),
{"SIGNAL_HOME_CHANNEL": "+1555000", "SIGNAL_HOME_CHANNEL_NAME": "Phone"},
("+1555000", "Phone"),
),
(
Platform.MATTERMOST,
PlatformConfig(
enabled=True,
token="mm-token",
extra={"url": "https://mm.example.com"},
),
{"MATTERMOST_HOME_CHANNEL": "ch_abc123", "MATTERMOST_HOME_CHANNEL_NAME": "General"},
("ch_abc123", "General"),
),
(
Platform.MATRIX,
PlatformConfig(
enabled=True,
token="syt_abc123",
extra={"homeserver": "https://matrix.example.org"},
),
{"MATRIX_HOME_ROOM": "!room123:example.org", "MATRIX_HOME_ROOM_NAME": "Bot Room"},
("!room123:example.org", "Bot Room"),
),
(
Platform.EMAIL,
PlatformConfig(
enabled=True,
extra={
"address": "hermes@test.com",
"imap_host": "imap.test.com",
"smtp_host": "smtp.test.com",
},
),
{"EMAIL_HOME_ADDRESS": "user@test.com", "EMAIL_HOME_ADDRESS_NAME": "Inbox"},
("user@test.com", "Inbox"),
),
(
Platform.SMS,
PlatformConfig(enabled=True, api_key="token_abc"),
{"SMS_HOME_CHANNEL": "+15559876543", "SMS_HOME_CHANNEL_NAME": "My Phone"},
("+15559876543", "My Phone"),
),
]
for platform, platform_config, env, expected in cases:
config = GatewayConfig(platforms={platform: platform_config})
with patch.dict(os.environ, env, clear=True):
_apply_env_overrides(config)
home = config.platforms[platform].home_channel
assert home is not None, f"{platform.value}: home_channel should not be None"
assert (home.chat_id, home.name) == expected, platform.value
class TestMultiplexProfilesEnvOverride:
"""GATEWAY_MULTIPLEX_PROFILES env override — the 3-tier precedence chain.
env (recognized token) > config.yaml (top-level or nested gateway.*) >
default False. A blank / unrecognized env value is treated as UNSET and
falls through to config (the empty-secret trap: a provisioned-but-empty Fly
secret arrives as "" and must not shadow a config.yaml opt-in).
"""
def _load(self, tmp_path, monkeypatch, config_text=None):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir(exist_ok=True)
if config_text is not None:
(hermes_home / "config.yaml").write_text(config_text, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
return load_gateway_config()
# ── Tier 1: env wins ──────────────────────────────────────────────────
def test_env_true_overrides_config_false(self, tmp_path, monkeypatch):
# THE discriminating test: env-set wins over an explicit config value.
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "1")
config = self._load(
tmp_path, monkeypatch, config_text="multiplex_profiles: false\n"
)
assert config.multiplex_profiles is True
# ── Tier 2: config.yaml when env unset ────────────────────────────────
def test_config_true_when_env_unset(self, tmp_path, monkeypatch):
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
config = self._load(
tmp_path, monkeypatch, config_text="multiplex_profiles: true\n"
)
assert config.multiplex_profiles is True
# ── The empty / unrecognized env trap: fall through, don't force off ──
def test_empty_env_does_not_shadow_config_true(self, tmp_path, monkeypatch):
# Provisioned-but-unpopulated Fly secret arrives as "". It must NOT
# turn OFF a config.yaml opt-in.
monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "")
config = self._load(
tmp_path, monkeypatch, config_text="multiplex_profiles: true\n"
)
assert config.multiplex_profiles is True
# ── Tier 3: default False ─────────────────────────────────────────────
# ── The resolver in isolation ─────────────────────────────────────────
class TestMultiplexProfilesConfig:
"""Tests for parsing multiplex_profiles (top-level and nested forms)."""
def test_multiplex_profiles_nested_under_gateway(self, tmp_path, monkeypatch):
"""gateway.multiplex_profiles (the form written by `hermes config set
gateway.multiplex_profiles true`) must be honored. Regression test for
the silent-fallback bug where the loader only forwarded the top-level
key, so users who wrote it under gateway: got multiplex_profiles=False
with no warning."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"gateway:\n multiplex_profiles: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is True, (
"gateway.multiplex_profiles: true was silently ignored — "
"loader only forwarded the top-level form"
)
def test_multiplex_profiles_explicit_top_level_false_not_consulting_nested(
self, tmp_path, monkeypatch
):
"""Lock in the `is None` vs `is False` distinction: when top-level is
explicitly false, the loader must forward False WITHOUT consulting the
nested form (so a stale `gateway.multiplex_profiles: true` cannot
silently re-enable multiplexing). Guards against a future regression
that flips the check to `not _mp`."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"multiplex_profiles: false\n"
"gateway:\n multiplex_profiles: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
config = load_gateway_config()
assert config.multiplex_profiles is False, (
"Explicit top-level false was overridden by nested true — "
"loader must respect top-level precedence when key is present"
)
class TestApiServerEnvOverride:
def test_env_key_does_not_reenable_explicitly_disabled_api_server(self):
"""An explicit ``platforms.api_server.enabled: false`` must survive
_apply_env_overrides() even when API_SERVER_KEY is present in the env.
Regression: _apply_env_overrides() force-set api_server.enabled = True
whenever API_SERVER_KEY (or API_SERVER_ENABLED) was set. In multiplex
mode a secondary profile pins ``api_server.enabled: false`` so it shares
the default profile's listener instead of binding its own port, but it
still inherits the process-level API_SERVER_KEY. The unconditional
re-enable flipped it back on and tripped the MultiplexConfigError check.
The fix honors the explicit disable, flagged by ``_enabled_explicit`` in
the platform's extra (set when the config.yaml pins enabled).
"""
config = GatewayConfig(
platforms={
Platform.API_SERVER: PlatformConfig(
enabled=False,
extra={"_enabled_explicit": True},
),
},
)
api_server_key = "secret-key-at-least-16"
with patch.dict(os.environ, {"API_SERVER_KEY": api_server_key}, clear=True):
_apply_env_overrides(config)
# Explicit disable wins over the env-var presence.
assert config.platforms[Platform.API_SERVER].enabled is False
# The key is still wired through for the shared listener.
assert config.platforms[Platform.API_SERVER].extra.get("key") == api_server_key