d10bb2ab6f
`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.
351 lines
15 KiB
Python
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 == {}
|