Files
aiturk-hermes-ide/tests/gateway/test_multiplex_adapter_registry.py
T

1224 lines
47 KiB
Python

"""Phase 3: secondary-profile adapter registry + same-token conflict detection."""
import logging
import asyncio
import threading
import time
import types
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.run import GatewayRunner
class _FakeAdapter:
def __init__(self, token=None, config=None):
self.token = token
self.config = config
class TestCredentialFingerprint:
def test_none_without_token(self):
assert GatewayRunner._adapter_credential_fingerprint(_FakeAdapter()) is None
def test_reads_photon_project_secret(self):
class _PhotonAdapter:
def __init__(self, secret):
self._project_secret = secret
fp1 = GatewayRunner._adapter_credential_fingerprint(
_PhotonAdapter("shared-project-secret")
)
fp2 = GatewayRunner._adapter_credential_fingerprint(
_PhotonAdapter("shared-project-secret")
)
assert fp1 == fp2
assert fp1 is not None
assert "shared-project-secret" not in fp1
def test_reads_feishu_app_id(self):
"""Feishu/Lark authenticates via app_id/app_secret, not a token.
Without _app_id in the fingerprint attribute list, every Feishu
adapter in a multiplexed gateway returns None here and the
same-credential conflict check is silently skipped — N profiles
spawn WebSocket clients against the same app, which evict each
other in a 1000 bye loop until all go offline.
"""
class _FeishuAdapter:
def __init__(self):
self._app_id = "cli_a1b2c3"
self._app_secret = "top-secret"
fp1 = GatewayRunner._adapter_credential_fingerprint(_FeishuAdapter())
fp2 = GatewayRunner._adapter_credential_fingerprint(_FeishuAdapter())
assert fp1 is not None
assert fp1 == fp2 # same app -> same fingerprint -> conflict detected
assert "cli_a1b2c3" not in fp1 # log-safe, never the raw credential
def test_distinct_feishu_app_ids_distinct_fp(self):
class _FeishuAdapter:
def __init__(self, app_id):
self._app_id = app_id
self._app_secret = "s"
fp_a = GatewayRunner._adapter_credential_fingerprint(_FeishuAdapter("app-A"))
fp_b = GatewayRunner._adapter_credential_fingerprint(_FeishuAdapter("app-B"))
assert fp_a is not None and fp_b is not None
assert fp_a != fp_b
@pytest.mark.parametrize("attr", ["_client_id", "_bot_id"])
def test_reads_app_style_ids_teams_wecom(self, attr):
"""Teams (_client_id) and WeCom (_bot_id) are the same class as Feishu:
id/secret pairs, no token — cloned profiles must collide."""
a = types.SimpleNamespace(**{attr: "app-1"})
b = types.SimpleNamespace(**{attr: "app-1"})
c = types.SimpleNamespace(**{attr: "app-2"})
fp = GatewayRunner._adapter_credential_fingerprint
assert fp(a) is not None and fp(a) == fp(b)
assert fp(a) != fp(c)
assert "app-1" not in fp(a)
def test_reads_config_token(self):
"""Adapters like Discord store token on `config`, not on self.
Without the config-token fallback, every Discord adapter in a
multiplexed gateway returns None here and the same-token conflict
check is silently skipped — N adapters start polling the same bot
token and race on every inbound message.
"""
class _Config:
token = "discord-bot-token"
class _ConfigBackedAdapter:
config = _Config()
fp = GatewayRunner._adapter_credential_fingerprint(_ConfigBackedAdapter())
assert fp is not None
assert "discord-bot-token" not in fp
assert len(fp) == 16
def test_distinct_config_tokens_distinct_fp(self):
class _CfgA:
token = "tok-A"
class _CfgB:
token = "tok-B"
class _A:
config = _CfgA()
class _B:
config = _CfgB()
a = GatewayRunner._adapter_credential_fingerprint(_A())
b = GatewayRunner._adapter_credential_fingerprint(_B())
assert a is not None and b is not None
assert a != b
class TestProfileMessageHandler:
@pytest.mark.asyncio
async def test_stamps_profile_on_unstamped_source(self):
runner = GatewayRunner.__new__(GatewayRunner)
seen = {}
async def _fake_handle(event):
seen["profile"] = event.source.profile
return "ok"
runner._handle_message = _fake_handle
handler = runner._make_profile_message_handler("coder")
class _Src:
profile = None
class _Evt:
source = _Src()
result = await handler(_Evt())
assert result == "ok"
assert seen["profile"] == "coder"
class TestProfileRuntimeStatus:
def test_base_adapter_uses_namespaced_platform_key(self, monkeypatch):
from gateway.platforms.base import BasePlatformAdapter
class _ConcreteAdapter(BasePlatformAdapter):
async def connect(self):
return True
async def disconnect(self):
return None
async def send(self, *_args, **_kwargs):
return None
async def get_chat_info(self, *_args, **_kwargs):
return None
adapter = _ConcreteAdapter.__new__(_ConcreteAdapter)
adapter.platform = Platform.DISCORD
adapter._runtime_status_platform_key = "reviewer:discord"
writes = []
monkeypatch.setattr(
"gateway.status.write_runtime_status",
lambda **kwargs: writes.append(kwargs),
)
adapter._write_runtime_status_safe("fatal", platform_state="fatal")
assert writes == [
{"platform": "reviewer:discord", "platform_state": "fatal"}
]
class _SecondaryRecoveryAdapter:
platform = Platform.DISCORD
def __init__(self, *, retryable=True):
self.fatal_error_retryable = retryable
self.fatal_error_code = "transport_stale" if retryable else "auth_failed"
self.fatal_error_message = "Gateway transport stale"
self.connected = False
self.disconnected = False
async def disconnect(self):
self.disconnected = True
def set_message_handler(self, handler):
self.message_handler = handler
def set_fatal_error_handler(self, handler):
self.fatal_error_handler = handler
def set_session_store(self, store):
self.session_store = store
def set_busy_session_handler(self, handler):
self.busy_session_handler = handler
def set_topic_recovery_fn(self, handler):
self.topic_recovery_fn = handler
def set_authorization_check(self, handler):
self.authorization_check = handler
def set_platform_event_handler(self, handler):
self.platform_event_handler = handler
def _secondary_recovery_runner(*, running=True):
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._running = running
runner._profile_adapters = {}
runner._profile_failed_platforms = {}
runner._background_tasks = set()
runner.session_store = object()
runner._handle_active_session_busy_message = object()
runner._recover_telegram_topic_thread_id = object()
runner._busy_text_mode = "queue"
runner._make_adapter_auth_check = lambda platform, profile_name=None: object()
runner._adapter_disconnect_timeout_secs = lambda: 0
runner._sync_voice_mode_state_to_adapter = lambda adapter: None
runner._redeliver_failed_obligations_for_platform = AsyncMock(return_value=0)
return runner
def _install_secondary_reconnect_context(
monkeypatch, runner, adapter, scoped_homes=None, hydration_flags=None
):
@contextmanager
def fake_scope(profile_home, *, hydrate_secrets=True):
if scoped_homes is not None:
scoped_homes.append(Path(profile_home))
if hydration_flags is not None:
hydration_flags.append(hydrate_secrets)
yield
monkeypatch.setattr(gateway_run, "_profile_runtime_scope", fake_scope)
monkeypatch.setattr(
"hermes_cli.profiles.get_profile_dir", lambda name: Path("/profiles") / name
)
monkeypatch.setattr(
"gateway.config.load_gateway_config",
lambda: GatewayConfig(
multiplex_profiles=True,
platforms={
Platform.DISCORD: PlatformConfig(
enabled=True, token="profile-token"
)
},
),
)
monkeypatch.setattr(runner, "_create_adapter", lambda platform, config: adapter)
class TestSecondaryProfileFatalRecovery:
@pytest.mark.asyncio
@pytest.mark.parametrize("entry", ["startup", "reconnect"])
async def test_secondary_hydrates_secrets_off_the_event_loop(self, monkeypatch, entry):
"""#99519 class: both secondary entry points (initial start + reconnect)
hydrate external secret sources in a worker thread, exactly once, and
enter the runtime scope with hydration disabled."""
runner = _secondary_recovery_runner()
replacement = _SecondaryRecoveryAdapter()
hydration_flags = []
_install_secondary_reconnect_context(
monkeypatch, runner, replacement, hydration_flags=hydration_flags
)
loop_thread_id = threading.get_ident()
hydration_started = threading.Event()
hydration_finished = threading.Event()
hydration_thread_ids = []
stop_ticker = asyncio.Event()
ticks_during_hydration = 0
def slow_hydrate(profile_home):
hydration_thread_ids.append(threading.get_ident())
hydration_started.set()
time.sleep(0.05)
hydration_finished.set()
async def ticker():
nonlocal ticks_during_hydration
while not stop_ticker.is_set():
if hydration_started.is_set() and not hydration_finished.is_set():
ticks_during_hydration += 1
await asyncio.sleep(0)
async def connect(adapter, platform, **_kwargs):
assert adapter is replacement
assert platform is Platform.DISCORD
return True
monkeypatch.setattr(
"hermes_cli.env_loader.hydrate_profile_secret_sources", slow_hydrate
)
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", connect)
monkeypatch.setattr(runner, "_connect_initial_adapter_with_timeout", connect)
monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {})
monkeypatch.setattr(runner, "_snapshot_profile_busy_modes", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.plugins.discover_plugins", lambda: None)
if entry == "startup":
coro = runner._start_one_profile_adapters(
"reviewer", Path("/profiles/reviewer"), {}
)
else:
coro = runner._run_secondary_profile_reconnect("reviewer", Platform.DISCORD)
ticker_task = asyncio.create_task(ticker())
work = asyncio.create_task(coro)
try:
assert await asyncio.to_thread(hydration_started.wait, 1.0)
await work
finally:
stop_ticker.set()
await ticker_task
assert len(hydration_thread_ids) == 1
assert hydration_thread_ids[0] != loop_thread_id
assert ticks_during_hydration > 0
assert hydration_flags and set(hydration_flags) == {False}
assert runner._profile_adapters["reviewer"][Platform.DISCORD] is replacement
@pytest.mark.asyncio
async def test_secondary_initial_connect_syncs_voice_mode_state(self, monkeypatch):
"""#84872: a secondary bot gets its persisted /voice state at INITIAL
connect, not only on reconnect."""
runner = _secondary_recovery_runner()
adapter = _SecondaryRecoveryAdapter()
_install_secondary_reconnect_context(monkeypatch, runner, adapter)
synced = []
runner._sync_voice_mode_state_to_adapter = synced.append
monkeypatch.setattr("hermes_cli.env_loader.hydrate_profile_secret_sources", lambda h: {})
monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {})
monkeypatch.setattr(runner, "_snapshot_profile_busy_modes", lambda *a, **k: None)
monkeypatch.setattr("hermes_cli.plugins.discover_plugins", lambda: None)
async def connect(a, platform):
return True
monkeypatch.setattr(runner, "_connect_initial_adapter_with_timeout", connect)
assert await runner._start_one_profile_adapters("reviewer", Path("/profiles/reviewer"), {}) == 1
assert synced == [adapter]
@pytest.mark.asyncio
async def test_retryable_secondary_fatal_reconnects_with_its_profile_scope(
self, monkeypatch
):
runner = _secondary_recovery_runner()
stale = _SecondaryRecoveryAdapter()
replacement = _SecondaryRecoveryAdapter()
runner._profile_adapters["reviewer"] = {Platform.DISCORD: stale}
scoped_homes: list[Path] = []
_install_secondary_reconnect_context(
monkeypatch, runner, replacement, scoped_homes
)
async def connect(adapter, platform, *, is_reconnect=False):
assert adapter is replacement
assert platform is Platform.DISCORD
assert is_reconnect is True
replacement.connected = True
return True
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", connect)
redelivery_homes = []
async def redeliver(platform, *, profile=None):
from hermes_constants import get_hermes_home
redelivery_homes.append(Path(get_hermes_home()))
return 0
runner._redeliver_failed_obligations_for_platform.side_effect = redeliver
await runner._handle_profile_adapter_fatal_error(
"reviewer", Platform.DISCORD, stale
)
assert stale.disconnected is True
assert Platform.DISCORD not in runner._profile_adapters["reviewer"]
tasks = list(runner._background_tasks)
assert len(tasks) == 1
await tasks[0]
assert runner._profile_adapters["reviewer"][Platform.DISCORD] is replacement
runner._redeliver_failed_obligations_for_platform.assert_awaited_once_with(
Platform.DISCORD, profile="reviewer"
)
assert scoped_homes
assert all(path == Path("/profiles/reviewer") for path in scoped_homes)
assert redelivery_homes
assert all(path != Path("/profiles/reviewer") for path in redelivery_homes)
@pytest.mark.asyncio
@pytest.mark.parametrize("connect_result", [True, False], ids=["success", "failure"])
async def test_secondary_reconnect_does_not_publish_after_shutdown(
self, monkeypatch, connect_result
):
runner = _secondary_recovery_runner()
runner._profile_failed_platforms["reviewer"] = {}
replacement = _SecondaryRecoveryAdapter()
_install_secondary_reconnect_context(monkeypatch, runner, replacement)
connect_started = asyncio.Event()
release_connect = asyncio.Event()
async def connect(adapter, platform, *, is_reconnect=False):
connect_started.set()
await release_connect.wait()
return connect_result
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", connect)
task = asyncio.create_task(
runner._run_secondary_profile_reconnect("reviewer", Platform.DISCORD)
)
runner._profile_failed_platforms["reviewer"][Platform.DISCORD] = task
await connect_started.wait()
runner._running = False
release_connect.set()
await asyncio.wait_for(task, timeout=0.2)
assert runner._profile_adapters == {}
assert replacement.disconnected is True
assert runner._profile_failed_platforms == {}
class TestSecondaryStartupFailureRecovery:
"""Cold-start connect failures must reach the same reconnect slot as
mid-run fatals — one unlucky connect window must not kill the platform
for the life of the process."""
@pytest.mark.asyncio
async def test_retryable_initial_failure_schedules_reconnect(
self, monkeypatch
):
runner = _secondary_recovery_runner()
failed = _SecondaryRecoveryAdapter()
replacement = _SecondaryRecoveryAdapter()
scoped_homes: list[Path] = []
_install_secondary_reconnect_context(
monkeypatch, runner, replacement, scoped_homes
)
# Startup creates `failed`; the reconnect runner creates `replacement`.
created = [failed, replacement]
monkeypatch.setattr(
runner, "_create_adapter", lambda platform, config: created.pop(0)
)
async def fail_initial_connect(adapter, platform):
return False
monkeypatch.setattr(
runner, "_connect_initial_adapter_with_timeout", fail_initial_connect
)
async def reconnect_ok(adapter, platform, *, is_reconnect=False):
assert is_reconnect is True
assert adapter is replacement
return True
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", reconnect_ok)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/reviewer", {}
)
assert connected == 0
assert failed.disconnected is True
assert Platform.DISCORD not in runner._profile_adapters.get(
"reviewer", {}
)
bridge = list(runner._background_tasks)
assert len(bridge) == 1
# Drive the bridge to completion; it hands off (immediately when the
# gateway is already running) to the regular reconnect task, which
# publishes the replacement and clears its own slot.
await asyncio.wait_for(bridge[0], timeout=0.5)
# The reconnect runner hops to a worker thread for secret hydration,
# so wait on a deadline rather than a fixed number of loop turns.
deadline = time.monotonic() + 1.0
while (
runner._profile_adapters.get("reviewer", {}).get(Platform.DISCORD)
is not replacement
and time.monotonic() < deadline
):
await asyncio.sleep(0.005)
assert (
runner._profile_adapters["reviewer"][Platform.DISCORD] is replacement
)
assert Platform.DISCORD not in runner._profile_failed_platforms.get(
"reviewer", {}
)
# Reconnect must have re-entered the profile's own runtime scope.
assert Path("/profiles/reviewer") in scoped_homes
assert all(
path in (Path("/tmp/reviewer"), Path("/profiles/reviewer"))
for path in scoped_homes
)
@pytest.mark.asyncio
async def test_raising_initial_connect_schedules_reconnect(
self, monkeypatch
):
runner = _secondary_recovery_runner()
failed = _SecondaryRecoveryAdapter()
replacement = _SecondaryRecoveryAdapter()
_install_secondary_reconnect_context(monkeypatch, runner, replacement)
created = [failed, replacement]
monkeypatch.setattr(
runner, "_create_adapter", lambda platform, config: created.pop(0)
)
async def explode(adapter, platform):
raise TimeoutError("initial connect budget exhausted")
monkeypatch.setattr(runner, "_connect_initial_adapter_with_timeout", explode)
async def reconnect_ok(adapter, platform, *, is_reconnect=False):
return True
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", reconnect_ok)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/reviewer", {}
)
assert connected == 0
assert failed.disconnected is True
bridge = list(runner._background_tasks)
assert len(bridge) == 1
await asyncio.wait_for(bridge[0], timeout=0.5)
# The reconnect runner hops to a worker thread for secret hydration,
# so wait on a deadline rather than a fixed number of loop turns.
deadline = time.monotonic() + 1.0
while (
runner._profile_adapters.get("reviewer", {}).get(Platform.DISCORD)
is not replacement
and time.monotonic() < deadline
):
await asyncio.sleep(0.005)
assert (
runner._profile_adapters["reviewer"][Platform.DISCORD] is replacement
)
assert Platform.DISCORD not in runner._profile_failed_platforms.get(
"reviewer", {}
)
@pytest.mark.asyncio
async def test_non_retryable_initial_failure_does_not_schedule(
self, monkeypatch
):
runner = _secondary_recovery_runner()
failed = _SecondaryRecoveryAdapter(retryable=False)
_install_secondary_reconnect_context(
monkeypatch, runner, _SecondaryRecoveryAdapter()
)
monkeypatch.setattr(runner, "_create_adapter", lambda platform, config: failed)
async def fail_initial_connect(adapter, platform):
return False
monkeypatch.setattr(
runner, "_connect_initial_adapter_with_timeout", fail_initial_connect
)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/reviewer", {}
)
assert connected == 0
assert failed.disconnected is True
assert runner._background_tasks == set()
assert runner._profile_failed_platforms == {}
@pytest.mark.asyncio
async def test_token_lock_initial_failure_parks_fatal_not_retried(
self, monkeypatch
):
"""Salvage of #83183 claim 2: a secondary whose token is held by a live
foreign gateway (``{scope}_lock``, emitted retryable by
``_acquire_platform_lock``) is an ownership conflict — park it fatal
like ``duplicate_credential`` instead of retry-storming the token."""
runner = _secondary_recovery_runner()
failed = _SecondaryRecoveryAdapter()
failed.fatal_error_code = "discord-bot-token_lock"
failed.fatal_error_message = "Discord bot token already in use (PID 4242)."
_install_secondary_reconnect_context(
monkeypatch, runner, _SecondaryRecoveryAdapter()
)
monkeypatch.setattr(runner, "_create_adapter", lambda platform, config: failed)
statuses = []
monkeypatch.setattr(
runner,
"_update_platform_runtime_status",
lambda key, **kw: statuses.append((key, kw)),
)
async def fail_initial_connect(adapter, platform):
return False
monkeypatch.setattr(
runner, "_connect_initial_adapter_with_timeout", fail_initial_connect
)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/reviewer", {}
)
assert connected == 0
assert failed.disconnected is True
assert runner._background_tasks == set()
assert runner._profile_failed_platforms == {}
assert statuses == [
(
"reviewer:discord",
{
"platform_state": "fatal",
"error_code": "discord-bot-token_lock",
"error_message": failed.fatal_error_message,
},
)
]
@pytest.mark.asyncio
async def test_handoff_failure_is_logged_not_raised(self, monkeypatch, caplog):
"""If the scheduler raises at bridge handoff, the parked task must not
die as an unretrieved-task exception — the failure surfaces in the log."""
runner = _secondary_recovery_runner()
failed = _SecondaryRecoveryAdapter()
_install_secondary_reconnect_context(
monkeypatch, runner, _SecondaryRecoveryAdapter()
)
monkeypatch.setattr(runner, "_create_adapter", lambda platform, config: failed)
async def fail_initial_connect(adapter, platform):
return False
monkeypatch.setattr(
runner, "_connect_initial_adapter_with_timeout", fail_initial_connect
)
def explode_at_handoff(profile_name, platform, adapter):
raise RuntimeError("scheduler exploded during handoff")
monkeypatch.setattr(
runner, "_schedule_secondary_profile_reconnect", explode_at_handoff
)
with caplog.at_level(logging.ERROR, logger="gateway.run"):
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/reviewer", {}
)
bridge = list(runner._background_tasks)
assert len(bridge) == 1
# Awaiting completes cleanly: the guard swallows the handoff
# failure instead of letting it escape as an unretrieved-task
# exception at GC time.
await asyncio.wait_for(bridge[0], timeout=0.5)
assert connected == 0
assert failed.disconnected is True
assert any(
record.levelno == logging.ERROR
and "secondary-startup-reconnect handoff failed" in record.getMessage()
for record in caplog.records
)
# Nothing was scheduled and no slot leaked behind the failed handoff.
assert Platform.DISCORD not in runner._profile_adapters.get("reviewer", {})
assert runner._profile_failed_platforms == {}
class TestSecondaryProfileConfigHandling:
"""Secondary config errors degrade only when the profile is safe to skip."""
@pytest.mark.asyncio
async def test_secondary_reports_all_port_binding_platforms(self, monkeypatch):
from gateway.run import SecondaryPortBindingConfigError
from gateway.config import GatewayConfig, Platform, PlatformConfig
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._profile_adapters = {}
reviewer_cfg = GatewayConfig(multiplex_profiles=True)
reviewer_cfg.platforms = {
# connection_mode=webhook: with #52563's conditional check merged,
# default (websocket) Feishu no longer binds a port — only webhook
# mode should be reported here.
Platform.FEISHU: PlatformConfig(
enabled=True, extra={"connection_mode": "webhook"}
),
Platform.WEBHOOK: PlatformConfig(enabled=True, extra={"port": 8644}),
Platform.TELEGRAM: PlatformConfig(enabled=True, token="t"),
}
monkeypatch.setattr(
"gateway.config.load_gateway_config", lambda: reviewer_cfg
)
with pytest.raises(SecondaryPortBindingConfigError) as ei:
await runner._start_one_profile_adapters("reviewer", "/tmp/x", {})
message = str(ei.value)
assert "feishu" in message
assert "webhook" in message
assert "telegram" not in message
assert "reviewer" not in runner._profile_adapters
def test_configured_secondary_adapter_namespaces_runtime_status(self):
runner = _secondary_recovery_runner()
adapter = _SecondaryRecoveryAdapter()
runner._configure_profile_adapter(adapter, "reviewer", Platform.DISCORD)
assert adapter._runtime_status_platform_key == "reviewer:discord"
@pytest.mark.asyncio
async def test_duplicate_credential_is_persisted_as_profile_fatal(
self, monkeypatch
):
runner = _secondary_recovery_runner()
config = GatewayConfig(
multiplex_profiles=True,
platforms={
Platform.DISCORD: PlatformConfig(
enabled=True, token="shared-discord-token"
)
},
)
adapter = _SecondaryRecoveryAdapter()
adapter.config = config.platforms[Platform.DISCORD]
writes = []
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: config)
monkeypatch.setattr(runner, "_create_adapter", lambda _p, _c: adapter)
monkeypatch.setattr(
runner,
"_update_platform_runtime_status",
lambda platform, **kwargs: writes.append((platform, kwargs)),
)
claim = runner._adapter_credential_claim(Platform.DISCORD, adapter)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/reviewer", {claim: "default"}
)
assert connected == 0
assert writes == [
(
"reviewer:discord",
{
"platform_state": "fatal",
"error_code": "duplicate_credential",
"error_message": (
"Profile 'default' and 'reviewer' both configure discord "
"with the same credential. Give each profile its own "
"discord credential."
),
},
)
]
@pytest.mark.asyncio
async def test_duplicate_listener_is_persisted_without_public_bind_details(
self, monkeypatch
):
class _ListenerAdapter(_SecondaryRecoveryAdapter):
_sidecar_bind = "127.0.0.1"
_sidecar_port = 8789
runner = _secondary_recovery_runner()
platform = Platform("photon")
config = GatewayConfig(
multiplex_profiles=True,
platforms={platform: PlatformConfig(enabled=True)},
)
adapter = _ListenerAdapter()
adapter.platform = platform
adapter.config = config.platforms[platform]
writes = []
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: config)
monkeypatch.setattr(runner, "_create_adapter", lambda _p, _c: adapter)
monkeypatch.setattr(
runner,
"_update_platform_runtime_status",
lambda key, **kwargs: writes.append((key, kwargs)),
)
claim = runner._adapter_listener_claim(platform, adapter)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/reviewer", {claim: "default"}
)
assert connected == 0
assert writes[0][0] == "reviewer:photon"
assert writes[0][1]["error_code"] == "duplicate_listener"
assert "127.0.0.1" not in writes[0][1]["error_message"]
assert "8789" not in writes[0][1]["error_message"]
@pytest.mark.asyncio
async def test_multiplexer_skips_bad_profile_and_continues(self, monkeypatch, caplog):
from pathlib import Path
from gateway.config import GatewayConfig
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(
multiplex_profiles=True,
multiplex_profile_allowlist=["bad", "good"],
)
runner.adapters = {}
runner._profile_adapters = {}
runner.pairing_stores = {
"default": MagicMock(),
"bad": MagicMock(),
"good": MagicMock(),
}
runner.pairing_store = runner.pairing_stores["default"]
async def fake_start_one(profile_name, profile_home, claimed):
if profile_name == "bad":
from gateway.run import SecondaryPortBindingConfigError
raise SecondaryPortBindingConfigError("bad enables webhook")
runner._profile_adapters[profile_name] = {}
return 2
def fake_profiles_to_serve(multiplex, profile_allowlist=None):
assert multiplex is True
assert profile_allowlist == ["bad", "good"]
return [
("default", Path("/tmp/default")),
("bad", Path("/tmp/bad")),
("good", Path("/tmp/good")),
]
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
fake_profiles_to_serve,
)
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name",
lambda: "default",
)
monkeypatch.setattr(runner, "_start_one_profile_adapters", fake_start_one)
status = {}
monkeypatch.setattr(
"gateway.status.write_runtime_status",
lambda **kwargs: status.update(kwargs),
)
caplog.set_level(logging.WARNING, logger="gateway.run")
connected = await runner._start_secondary_profile_adapters()
assert connected == 2
assert status["served_profiles"] == ["default", "bad", "good"]
assert "good" in runner._profile_adapters
assert "bad" not in runner._profile_adapters
assert "Skipping secondary profile 'bad'" in caplog.text
@pytest.mark.asyncio
async def test_multiplexer_propagates_security_config_error(self, monkeypatch):
from pathlib import Path
from gateway.config import GatewayConfig
from gateway.run import MultiplexConfigError
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner.adapters = {}
runner._profile_adapters = {}
async def fake_start_one(profile_name, profile_home, claimed):
raise MultiplexConfigError(
f"Profile '{profile_name}' enables open policy without allow-all opt-in"
)
monkeypatch.setattr(
"hermes_cli.profiles.profiles_to_serve",
lambda multiplex, profile_allowlist=None: [
("default", Path("/tmp/default")),
("unsafe", Path("/tmp/unsafe")),
],
)
monkeypatch.setattr(
"hermes_cli.profiles.get_active_profile_name",
lambda: "default",
)
monkeypatch.setattr(runner, "_start_one_profile_adapters", fake_start_one)
with pytest.raises(MultiplexConfigError, match="open policy"):
await runner._start_secondary_profile_adapters()
@pytest.mark.asyncio
async def test_secondary_distinct_photon_credentials_distinct_ports_connect(
self, monkeypatch
):
"""Multiplexing remains supported when Photon sidecars cannot collide."""
from gateway.config import GatewayConfig, Platform, PlatformConfig
class _PhotonAdapter:
def __init__(self, secret, port):
self._project_secret = secret
self._sidecar_bind = "127.0.0.1"
self._sidecar_port = port
self.platform = Platform("photon")
self.connected = False
self.disconnected = False
self.config = PlatformConfig(enabled=True)
def __getattr__(self, name):
if name.startswith("set_"):
return lambda *args, **kwargs: None
raise AttributeError(name)
async def disconnect(self):
self.disconnected = True
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._profile_adapters = {}
runner.session_store = None
runner._busy_text_mode = "queue"
photon = Platform("photon")
reviewer_cfg = GatewayConfig(multiplex_profiles=True)
reviewer_cfg.platforms = {photon: PlatformConfig(enabled=True)}
primary = _PhotonAdapter("primary-secret", 8789)
secondary = _PhotonAdapter("different-secret", 8790)
claimed = {
GatewayRunner._adapter_listener_claim(photon, primary): "default"
}
async def _connect(adapter, platform, **_kw):
adapter.connected = True
return True
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: reviewer_cfg)
monkeypatch.setattr(runner, "_create_adapter", lambda p, c: secondary)
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", _connect)
monkeypatch.setattr(
runner, "_make_adapter_auth_check", lambda p, **kwargs: None
)
connected = await runner._start_one_profile_adapters(
"reviewer", "/tmp/x", claimed
)
assert connected == 1
assert secondary.connected is True
assert secondary.disconnected is False
assert runner._profile_adapters["reviewer"][photon] is secondary
@pytest.mark.asyncio
async def test_failed_photon_connect_releases_listener_for_later_profile(
self, monkeypatch
):
"""A failed sidecar must not reserve an endpoint it never owned."""
from gateway.config import GatewayConfig, Platform, PlatformConfig
class _PhotonAdapter:
def __init__(self, secret, should_connect):
self._project_secret = secret
self._sidecar_bind = "127.0.0.1"
self._sidecar_port = 8789
self.platform = Platform("photon")
self.should_connect = should_connect
self.disconnected = False
self.config = PlatformConfig(enabled=True)
def __getattr__(self, name):
if name.startswith("set_"):
return lambda *args, **kwargs: None
raise AttributeError(name)
async def disconnect(self):
self.disconnected = True
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._profile_adapters = {}
runner.session_store = None
runner._busy_text_mode = "queue"
photon = Platform("photon")
profile_cfg = GatewayConfig(multiplex_profiles=True)
profile_cfg.platforms = {photon: PlatformConfig(enabled=True)}
failed = _PhotonAdapter("failed-secret", False)
later = _PhotonAdapter("later-secret", True)
adapters = iter((failed, later))
claimed = {}
async def _connect(adapter, platform, **_kw):
return adapter.should_connect
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: profile_cfg)
monkeypatch.setattr(runner, "_create_adapter", lambda p, c: next(adapters))
monkeypatch.setattr(runner, "_connect_adapter_with_timeout", _connect)
monkeypatch.setattr(
runner, "_make_adapter_auth_check", lambda p, **kwargs: None
)
first = await runner._start_one_profile_adapters("broken", "/tmp/x", claimed)
second = await runner._start_one_profile_adapters("later", "/tmp/y", claimed)
assert first == 0
assert failed.disconnected is True
assert second == 1
assert runner._profile_adapters["later"][photon] is later
@pytest.mark.asyncio
async def test_secondary_teams_uses_degradable_error(self, monkeypatch):
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.run import SecondaryPortBindingConfigError
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._profile_adapters = {}
reviewer_cfg = GatewayConfig(multiplex_profiles=True)
reviewer_cfg.platforms = {
Platform("teams"): PlatformConfig(enabled=True, extra={"port": 3978}),
}
monkeypatch.setattr(
"gateway.config.load_gateway_config", lambda: reviewer_cfg
)
with pytest.raises(SecondaryPortBindingConfigError) as exc_info:
await runner._start_one_profile_adapters("reviewer", "/tmp/x", {})
assert "teams" in str(exc_info.value)
assert "reviewer" in str(exc_info.value)
assert "reviewer" not in runner._profile_adapters
@pytest.mark.asyncio
async def test_secondary_profile_adapter_start_skips_whatsapp(self, monkeypatch):
"""WhatsApp is shared process-level ingress like Relay: the bridge is
one authenticated session tied to a single phone number, so a
credential-less secondary profile must be skipped (not stall startup
in a connect/retry loop) while its other platforms start normally."""
runner = _secondary_recovery_runner()
direct = _SecondaryRecoveryAdapter()
_install_secondary_reconnect_context(monkeypatch, runner, direct)
monkeypatch.setattr(
"gateway.config.load_gateway_config",
lambda: GatewayConfig(
multiplex_profiles=True,
platforms={
Platform.WHATSAPP: PlatformConfig(enabled=True),
Platform.DISCORD: PlatformConfig(enabled=True, token="profile-token"),
},
),
)
factory_calls = []
def _create_adapter(platform, config):
factory_calls.append(platform)
return direct
async def _connect(adapter, platform):
return True
monkeypatch.setattr(runner, "_create_adapter", _create_adapter)
monkeypatch.setattr(runner, "_connect_initial_adapter_with_timeout", _connect)
connected = await runner._start_one_profile_adapters("clientbot", "/tmp/x", {})
assert connected == 1
assert factory_calls == [Platform.DISCORD]
assert runner._profile_adapters["clientbot"] == {Platform.DISCORD: direct}
class TestSecondaryProfileHookRegistration:
"""A secondary profile's own `hooks:` block must register on ITS
plugin manager, not just the root/default profile's (#92672).
Startup only calls agent.shell_hooks/outbound_webhooks
register_from_config() once, against the root config, before any
profile scope exists. Without a matching call inside
_start_one_profile_adapters, a secondary profile's config.yaml
`hooks:` block (shell hooks and outbound webhooks) never registers.
"""
@pytest.mark.asyncio
async def test_registers_shell_hooks_and_webhooks_for_secondary_profile(
self, monkeypatch
):
runner = _secondary_recovery_runner()
config = GatewayConfig(multiplex_profiles=True, platforms={})
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: config)
profile_cfg = {
"hooks": {
"pre_tool_call": [
{"matcher": "write_file", "command": "~/.hermes/deny.sh"}
],
"outbound": [
{"url": "http://127.0.0.1:9000/hook", "events": ["on_session_end"]}
],
}
}
monkeypatch.setattr("hermes_cli.config.load_config", lambda: profile_cfg)
seen = []
monkeypatch.setattr(
"agent.shell_hooks.register_from_config",
lambda cfg, **kwargs: seen.append(("shell", cfg)) or [],
)
monkeypatch.setattr(
"agent.outbound_webhooks.register_from_config",
lambda cfg: seen.append(("webhook", cfg)) or [],
)
await runner._start_one_profile_adapters("second", "/tmp/second", {})
assert ("shell", profile_cfg) in seen
assert ("webhook", profile_cfg) in seen
class TestFeishuPortBindingConditional:
"""Feishu websocket mode does NOT bind a port; only webhook mode does (#52563)."""
@pytest.mark.asyncio
async def test_feishu_websocket_mode_not_rejected(self, monkeypatch):
"""Feishu in websocket mode (the default) should NOT raise MultiplexConfigError."""
from gateway.run import MultiplexConfigError
from gateway.config import GatewayConfig, Platform, PlatformConfig
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._profile_adapters = {}
reviewer_cfg = GatewayConfig(multiplex_profiles=True)
reviewer_cfg.platforms = {
Platform.FEISHU: PlatformConfig(
enabled=True,
extra={"app_id": "cli_xxx", "app_secret": "sec", "connection_mode": "websocket"},
),
}
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: reviewer_cfg)
monkeypatch.setattr(runner, "_create_adapter", lambda p, c: None)
connected = await runner._start_one_profile_adapters("reviewer", "/tmp/x", {})
assert connected == 0 # no error, just nothing connected
class TestSecondarySkipsCredentiallessPlatforms:
"""#84079 — multiplex must not build adapters for platforms a profile
has no credential for.
The shared config.yaml enables a platform once; under multiplex every
secondary profile reloads it inside its own secret scope, so a profile
whose scope lacks the platform credential resolves ``enabled=True`` with
an empty token. Constructing an adapter anyway treats every profile as
configured for the platform — one inbound message fans out across all of
them. These tests lock the credential gate on the secondary startup path
(the primary path got the same gate in #64674; the reconnect path shares
the helper). Also reported independently in #72313.
"""
def _make_runner(self, monkeypatch, profile_cfg):
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(multiplex_profiles=True)
runner._profile_adapters = {}
runner.adapters = {}
created = []
def fake_create(platform, platform_config):
created.append((platform, platform_config))
return _FakeAdapter(token=platform_config.token or None)
monkeypatch.setattr("gateway.config.load_gateway_config", lambda: profile_cfg)
monkeypatch.setattr(runner, "_create_adapter", fake_create)
monkeypatch.setattr(runner, "_configure_profile_adapter", lambda *a, **k: None)
monkeypatch.setattr(
runner,
"_connect_initial_adapter_with_timeout",
AsyncMock(return_value=True),
)
return runner, created
@pytest.mark.asyncio
async def test_credentialless_platform_builds_no_adapter(self, monkeypatch, tmp_path):
"""Enabled-in-YAML but no credential in the profile scope -> no adapter."""
from gateway.config import GatewayConfig, Platform, PlatformConfig
profile_cfg = GatewayConfig(multiplex_profiles=True)
profile_cfg.platforms = {
# Shared config.yaml enables Slack; profile-b's .env has no
# SLACK_BOT_TOKEN, so its scoped load resolves token="" but
# keeps enabled=True (#84079).
Platform.SLACK: PlatformConfig(enabled=True, token=""),
Platform.TELEGRAM: PlatformConfig(enabled=True, token="telegram-token-b"),
}
runner, created = self._make_runner(monkeypatch, profile_cfg)
connected = await runner._start_one_profile_adapters("profile-b", tmp_path, {})
# Only Telegram (which profile-b has its own credential for) gets an
# adapter; Slack is skipped instead of fanning out a turn per profile.
assert [p for p, _ in created] == [Platform.TELEGRAM]
assert connected == 1
assert Platform.TELEGRAM in runner._profile_adapters["profile-b"]
assert Platform.SLACK not in runner._profile_adapters["profile-b"]
@pytest.mark.asyncio
async def test_profile_with_own_credential_still_connects(self, monkeypatch, tmp_path):
"""A profile that defines its own credential keeps its adapter."""
from gateway.config import GatewayConfig, Platform, PlatformConfig
profile_cfg = GatewayConfig(multiplex_profiles=True)
profile_cfg.platforms = {
Platform.SLACK: PlatformConfig(enabled=True, token="slack-token-b"),
}
runner, created = self._make_runner(monkeypatch, profile_cfg)
connected = await runner._start_one_profile_adapters("profile-b", tmp_path, {})
assert connected == 1
assert created == [(Platform.SLACK, profile_cfg.platforms[Platform.SLACK])]
assert Platform.SLACK in runner._profile_adapters["profile-b"]