"""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"]