fix(gateway): read adapter token from config for fingerprint check

_adapter_credential_fingerprint only looked at adapter.token directly,
but Discord (and similar) adapters store the bot token on their config
sub-object, not on self. Every Discord adapter in a multiplexed
gateway therefore returned None, the same-token conflict check was
silently skipped, and N adapters all polled the same bot token —
producing a per-message race where whichever adapter won the GIL
answered the user.

Adds a config-token fallback (token, then bot_token) so the check
actually fires for config-backed adapters. Direct adapter.token
still takes precedence when both exist.

Tests cover: config-backed token produces a fingerprint, distinct
tokens produce distinct fingerprints, direct token wins over config,
config without token attributes returns None.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Burgunthy 2026-06-28 05:51:16 +09:00 committed by Teknium
parent a55523fd6d
commit a1d6654264
2 changed files with 66 additions and 0 deletions

View file

@ -8785,6 +8785,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if isinstance(val, str) and val.strip():
token = val.strip()
break
# Many adapters (e.g. Discord) store the token on their `config`
# sub-object rather than directly on the adapter. Without this lookup
# those adapters all return None here, the same-token conflict check
# is silently skipped, and every profile's adapter for that platform
# starts polling the same bot token — producing a per-message race
# for which adapter answers. See test_reads_config_token.
if not token:
cfg = getattr(adapter, "config", None)
if cfg is not None:
for attr in ("token", "bot_token"):
val = getattr(cfg, attr, None)
if isinstance(val, str) and val.strip():
token = val.strip()
break
if not token:
config = getattr(adapter, "config", None)
val = getattr(config, "token", None)

View file

@ -45,6 +45,58 @@ class TestCredentialFingerprint:
assert "config-token" not in fp
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
def test_direct_token_takes_precedence_over_config(self):
"""If both `adapter.token` and `adapter.config.token` exist, direct wins."""
class _Cfg:
token = "from-config"
class _Both:
token = "from-direct"
config = _Cfg()
fp = GatewayRunner._adapter_credential_fingerprint(_Both())
import hashlib
expected = hashlib.sha256(b"hermes-mux:from-direct").hexdigest()[:16]
assert fp == expected
def test_config_without_token_returns_none(self):
"""config present but no token attribute → None (no false positive)."""
class _Cfg:
pass
class _Adapter:
config = _Cfg()
assert GatewayRunner._adapter_credential_fingerprint(_Adapter()) is None
class TestProfileMessageHandler:
@pytest.mark.asyncio
async def test_stamps_profile_on_unstamped_source(self):