diff --git a/gateway/run.py b/gateway/run.py index aa999b53a992..6b4e64b091d6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10342,12 +10342,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if owner is not None: logger.error( "Profile '%s' and '%s' both configure %s with the same " - "credential — refusing to start the duplicate (a single " - "bot token cannot be polled twice). Give each profile its " - "own %s credential.", + "credential — refusing to start the duplicate (one " + "credential cannot be consumed twice). Give each profile " + "its own %s credential.", owner, profile_name, platform.value, platform.value, ) - await self._safe_adapter_disconnect(adapter, platform) + # This adapter has not connected and therefore owns no + # resources to clean up. Calling disconnect here can mutate + # the shared platform state and, for a same-credential Photon + # adapter, shut down the primary profile's live sidecar. continue claimed[(platform, fp)] = profile_name @@ -10591,13 +10594,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew def _adapter_credential_fingerprint(adapter: Any) -> Optional[str]: """Return a stable, log-safe fingerprint of an adapter's credential. - Used only to detect two profiles claiming the same bot token. Returns a - salted hash (never the token itself) of the adapter's primary - credential, or None when no credential is discoverable (in which case - we don't attempt conflict detection for it). + Used only to detect two profiles claiming the same platform credential. + Returns a salted hash (never the credential itself) of the adapter's + primary credential, or None when no credential is discoverable (in + which case we don't attempt conflict detection for it). """ token = None - for attr in ("token", "bot_token", "_token", "api_token", "_bot_token"): + for attr in ( + "token", + "bot_token", + "_token", + "api_token", + "_bot_token", + # Photon/Spectrum authenticates with project credentials instead + # of a bot token. Including its secret keeps multiplexed profiles + # from spawning competing sidecars for the same account and port. + "_project_secret", + ): val = getattr(adapter, attr, None) if isinstance(val, str) and val.strip(): token = val.strip() diff --git a/tests/gateway/test_multiplex_adapter_registry.py b/tests/gateway/test_multiplex_adapter_registry.py index 2b228943790c..885ac3a67659 100644 --- a/tests/gateway/test_multiplex_adapter_registry.py +++ b/tests/gateway/test_multiplex_adapter_registry.py @@ -41,6 +41,22 @@ class TestCredentialFingerprint: self.bot_token = "alt-token" assert GatewayRunner._adapter_credential_fingerprint(_AltAdapter()) is not 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_platform_config_token(self): class _Config: token = "config-token" @@ -718,8 +734,10 @@ class TestSecondaryProfileConfigHandling: assert connect_calls == [(relay, Platform.RELAY)] @pytest.mark.asyncio - async def test_secondary_same_config_token_is_refused(self, monkeypatch): - """Adapters that keep their token on config still trip the mux guard.""" + async def test_secondary_same_config_token_is_refused_without_disconnect( + self, monkeypatch + ): + """A never-connected duplicate must not disturb shared live state.""" from gateway.config import GatewayConfig, Platform, PlatformConfig class _ConfigTokenAdapter: @@ -762,7 +780,7 @@ class TestSecondaryProfileConfigHandling: ) assert connected == 0 - assert duplicate.disconnected is True + assert duplicate.disconnected is False assert runner._profile_adapters["reviewer"] == {} def test_port_binding_set_covers_known_listeners(self):