Files
hermes-agent/tests/gateway/test_multiplex_primary_token_scope.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

351 lines
15 KiB
Python

"""#64674 — multiplex primary gateway must not fail forever without bot tokens.
When gateway.multiplex_profiles is on and TELEGRAM_BOT_TOKEN lives only in a
secondary profile's .env, the default-profile primary adapter used to start
with an empty token, log "No bot token configured", and queue an infinite
reconnect loop. Secondary profiles already load under _profile_runtime_scope;
this suite locks the complementary primary-path fixes:
1. load_gateway_config_for_runner reloads under the default profile secret scope
when multiplex is on (so default .env tokens resolve like secondary loads).
2. Primary startup skips token platforms that still have no credential under
multiplex instead of connecting-and-failing forever.
3. The reconnect watcher drops empty-token queued configs.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
@pytest.fixture(autouse=True)
def _reset_multiplex_flag():
from agent import secret_scope as ss
ss.set_multiplex_active(False)
yield
ss.set_multiplex_active(False)
class TestLoadGatewayConfigForRunner:
def test_unscoped_when_multiplex_off(self, tmp_path, monkeypatch):
from gateway import run as run_mod
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("TELEGRAM_BOT_TOKEN=from-default-env\n", encoding="utf-8")
(home / "config.yaml").write_text("gateway:\n multiplex_profiles: false\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
# Without multiplex, dotenv is still loaded into os.environ by the
# normal env loader in real gateways; here we only assert the helper
# returns a non-multiplex config without requiring a scope.
cfg = run_mod.load_gateway_config_for_runner()
assert cfg.multiplex_profiles is False
def test_scoped_reload_still_sees_container_api_server_env(self, tmp_path, monkeypatch):
"""#69379 — container-env API_SERVER_* visible during the scoped reload.
Docker/systemd deployments enable the api_server platform via the
process environment (compose ``environment:`` block), not the profile
``.env``. The multiplex runner reload happens inside the default
profile's secret scope; the listener settings are on the global
allowlist (deployment config, not profile secrets) so they must stay
visible there — while API_SERVER_KEY (a credential) still resolves
through the profile scope.
"""
from agent import secret_scope as ss
from gateway import run as run_mod
home = tmp_path / "home"
home.mkdir()
# Credentials belong in the profile .env; listener settings do not.
(home / ".env").write_text(
"TELEGRAM_BOT_TOKEN=default-profile-token-123\n"
"API_SERVER_KEY=profile-scoped-key-0123456789abcdef\n",
encoding="utf-8",
)
(home / "config.yaml").write_text(
"gateway:\n multiplex_profiles: true\n", encoding="utf-8"
)
monkeypatch.setenv("HERMES_HOME", str(home))
# Listener settings live ONLY in os.environ — the Docker compose case.
monkeypatch.setenv("API_SERVER_ENABLED", "true")
monkeypatch.setenv("API_SERVER_HOST", "0.0.0.0")
monkeypatch.setenv("API_SERVER_PORT", "8642")
monkeypatch.delenv("API_SERVER_KEY", raising=False)
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
monkeypatch.setattr(run_mod, "get_hermes_home", lambda: home)
monkeypatch.setattr(run_mod, "_hermes_home", home)
# Model the real multiplexed gateway: run.py flips the runtime flag
# before the runner reload, making any installed scope authoritative.
ss.set_multiplex_active(True)
cfg = run_mod.load_gateway_config_for_runner()
assert cfg.multiplex_profiles is True
# Telegram token from the profile scope (.env)
tg = cfg.platforms.get(Platform.TELEGRAM)
assert tg is not None
assert tg.token == "default-profile-token-123"
# api_server present: key from the profile scope, listener settings
# from the container environment via the global allowlist.
api = cfg.platforms.get(Platform.API_SERVER)
assert api is not None, (
"api_server should be enabled from container env even inside "
"the scoped runner reload (#69379)"
)
assert api.enabled is True
assert api.extra.get("key") == "profile-scoped-key-0123456789abcdef"
assert api.extra.get("host") == "0.0.0.0"
assert api.extra.get("port") == 8642
class TestPlatformHasBotCredential:
def test_telegram_empty_token_false(self):
from gateway.run import _platform_has_bot_credential
assert _platform_has_bot_credential(
Platform.TELEGRAM, PlatformConfig(enabled=True, token="")
) is False
assert _platform_has_bot_credential(
Platform.TELEGRAM, PlatformConfig(enabled=True, token=None)
) is False
def test_matrix_password_login_is_a_credential(self):
"""Matrix password auth has no .token but is fully reconnectable.
MATRIX_USER_ID + MATRIX_PASSWORD with no MATRIX_ACCESS_TOKEN is a
supported setup (build_config puts it on extra). Treating it as
credential-less evicted it from the reconnect queue on the first
transient failure, so a momentary DNS blip took Matrix down until
the gateway was restarted by hand.
"""
from gateway.run import _platform_has_bot_credential
cfg = PlatformConfig(enabled=True)
cfg.extra = {
"homeserver": "https://matrix.example.org",
"user_id": "@bot:matrix.example.org",
"password": "hunter2",
}
assert _platform_has_bot_credential(Platform.MATRIX, cfg) is True
@pytest.mark.parametrize(
"extra",
[
{},
{"homeserver": "https://matrix.example.org", "password": "hunter2"},
{"user_id": "@bot:matrix.example.org", "password": "hunter2"},
{"homeserver": "https://matrix.example.org", "user_id": "@bot:m.example.org"},
{"homeserver": " ", "user_id": " ", "password": " "},
],
ids=["empty", "no-user-id", "no-homeserver", "no-password", "blank"],
)
def test_matrix_incomplete_password_config_still_dropped(self, extra, monkeypatch):
"""An incomplete Matrix config can never connect — keep evicting it.
Guards the #64674 intent, and specifically pins "read extra, not the
environment". A fully-populated MATRIX_* environment is set here on
purpose: on a real host those vars are present (build_config exports
them, and importing gateway.run loads ~/.hermes/.env), so an
implementation that falls back to os.getenv would report every Matrix
config as credentialed and never evict anything.
conftest sandboxes HERMES_HOME and scrubs MATRIX_* from the
environment, so without these explicit setenv calls this test would
pass against an env-reading implementation and guard nothing.
"""
from gateway.run import _platform_has_bot_credential
monkeypatch.setenv("MATRIX_HOMESERVER", "https://env.example.org")
monkeypatch.setenv("MATRIX_USER_ID", "@envbot:env.example.org")
monkeypatch.setenv("MATRIX_PASSWORD", "env-password")
cfg = PlatformConfig(enabled=True)
cfg.extra = dict(extra)
assert _platform_has_bot_credential(Platform.MATRIX, cfg) is False
class TestPrimaryStartupSkipsEmptyTokenUnderMultiplex:
@pytest.mark.asyncio
async def test_skips_empty_telegram_when_multiplex_on(self, monkeypatch):
from gateway.run import GatewayRunner
cfg = GatewayConfig(multiplex_profiles=True)
cfg.platforms[Platform.TELEGRAM] = PlatformConfig(
enabled=True, token="" # empty — lives on secondary only
)
runner = GatewayRunner.__new__(GatewayRunner)
# Minimal init of attributes used by the start loop body we call.
runner.config = cfg
runner.adapters = {}
runner._failed_platforms = {}
runner._profile_adapters = {}
runner._busy_text_mode = "off"
runner.session_store = MagicMock()
runner._shutdown_event = MagicMock()
runner._running = True
created = []
def _fake_create(platform, platform_config):
created.append(platform)
return MagicMock()
runner._create_adapter = _fake_create # type: ignore[method-assign]
runner._abort_startup_if_shutdown_requested = MagicMock(return_value=False) # type: ignore
runner._update_platform_runtime_status = MagicMock() # type: ignore
runner._start_secondary_profile_adapters = MagicMock(return_value=0) # type: ignore
# Make the secondary call awaitable
async def _sec():
return 0
runner._start_secondary_profile_adapters = _sec # type: ignore
# We only want the primary platform loop; extract and run a thin
# stand-in by invoking the real loop logic via a partial start is
# heavy. Instead assert the skip helper path by simulating the
# condition the start() loop uses.
from gateway.run import _platform_has_bot_credential
skipped = []
for platform, platform_config in cfg.platforms.items():
if not platform_config.enabled:
continue
if cfg.multiplex_profiles and not _platform_has_bot_credential(
platform, platform_config
):
skipped.append(platform)
continue
created.append(platform)
assert skipped == [Platform.TELEGRAM]
assert created == []
class TestPrimaryMessageRuntimeScope:
@pytest.mark.asyncio
async def test_default_profile_prompt_gate_sees_its_scoped_token(
self, tmp_path, monkeypatch
):
from agent import secret_scope
from gateway import run as run_mod
from gateway.run import GatewayRunner
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text(
"DISCORD_BOT_TOKEN=default-profile-token\n", encoding="utf-8"
)
(home / "config.yaml").write_text(
"platform_toolsets:\n discord:\n - discord\n", encoding="utf-8"
)
monkeypatch.setattr(run_mod, "get_hermes_home", lambda: home)
monkeypatch.setenv("DISCORD_BOT_TOKEN", "wrong-process-token")
secret_scope.set_multiplex_active(True)
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
async def _handle_message(_event):
from gateway.session import _discord_tools_loaded
return _discord_tools_loaded()
runner._handle_message = _handle_message # type: ignore[method-assign]
handler = runner._primary_message_handler()
assert await handler(SimpleNamespace(source=SimpleNamespace(profile=None))) is True
with pytest.raises(secret_scope.UnscopedSecretError):
secret_scope.get_secret("DISCORD_BOT_TOKEN")
@pytest.mark.asyncio
async def test_primary_busy_path_authorizes_in_transport_scope(
self, tmp_path, monkeypatch
):
"""Routed busy messages must read the admitting adapter's allowlist.
Signal on Rémi's host is a primary/default transport that routes turns
into Sharik via gateway.profile_routes. A follow-up can arrive while
the Sharik agent is still busy; that busy callback used to skip the
default-profile scoping wrapper and check SIGNAL_ALLOWED_USERS inside
Sharik's unrelated secret scope.
"""
from agent import secret_scope
from gateway import run as run_mod
from gateway.run import GatewayRunner
from gateway.session import SessionSource
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text(
"SIGNAL_ALLOWED_USERS=+15550001111\n"
"SIGNAL_ALLOW_ALL_USERS=false\n",
encoding="utf-8",
)
sharik = tmp_path / "profiles" / "sharik"
sharik.mkdir(parents=True)
(sharik / ".env").write_text("# no Signal allowlist here\n", encoding="utf-8")
(sharik / "config.yaml").write_text("{}\n", encoding="utf-8")
monkeypatch.setattr(run_mod, "get_hermes_home", lambda: home)
secret_scope.set_multiplex_active(True)
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._resolve_profile_home_for_source = lambda _source: sharik # type: ignore[method-assign]
runner._session_key_for_source = lambda source: f"agent:{source.profile}:signal:dm:{source.chat_id}" # type: ignore[method-assign]
runner._is_user_authorized_for_source = GatewayRunner._is_user_authorized_for_source.__get__(runner) # type: ignore[method-assign]
async def _busy(event, session_key):
assert session_key == "agent:sharik:signal:dm:+15550001111"
# This is what _handle_active_session_busy_message now calls.
return runner._is_user_authorized_for_source(event.source)
runner._handle_active_session_busy_message = _busy # type: ignore[method-assign]
handler = runner._primary_busy_session_handler()
event = SimpleNamespace(
source=SessionSource(
platform=Platform.SIGNAL,
chat_id="+15550001111",
chat_type="dm",
user_id="+15550001111",
user_name="Remi",
profile="sharik",
)
)
assert await handler(event, "stale-unrouted-key") is True
with pytest.raises(secret_scope.UnscopedSecretError):
secret_scope.get_secret("SIGNAL_ALLOWED_USERS")
class TestReconnectDropsEmptyToken:
@pytest.mark.asyncio
async def test_empty_token_removed_from_queue(self):
from gateway.run import GatewayRunner, _platform_has_bot_credential
from gateway.config import Platform, PlatformConfig
# Unit-level: the branch condition the watcher uses.
platform = Platform.TELEGRAM
platform_config = PlatformConfig(enabled=True, token="")
failed = {
platform: {
"config": platform_config,
"attempts": 3,
"next_retry": 0,
}
}
assert not _platform_has_bot_credential(platform, platform_config)
# Simulate watcher drop
del failed[platform]
assert failed == {}