fix(gateway): profile_routes no longer hijack DMs to a dedicated secondary bot (#104933)

Telegram DM chat_id == user_id for every bot, so a `chat_id` route meant to
pin a user's DM with the SHARED bot to profile `ops` also captured that
user's DM with team_b's dedicated bot: the turn, session key, state.db and
secrets were ops's while the reply left via team_b's transport.

`ProfileRoute` gains an optional `bot_profile` discriminator (None = the
default profile's bot) and `matches()` requires it to equal the receiving
adapter's owner profile. `build_source` passes `_owner_profile` and stamps it
as the fallback profile for secondary adapters; `_profile_name_for_source`
derives it from the transport ref for sources built elsewhere.
Secondary→secondary routes remain possible by naming the bot explicitly.

Salvages the discriminator idea from #105040 and #105049 in a smaller shape
(no bot-username sniffing across adapter internals; the owning profile is
already declared at `set_owner_profile`).

Co-authored-by: 0xAlyDev <agentai891@gmail.com>
Co-authored-by: Ahmett101 <Ahmett101@users.noreply.github.com>
This commit is contained in:
Teknium
2026-09-11 09:17:40 -07:00
parent 350b9c90fa
commit 27768a9fa5
4 changed files with 64 additions and 8 deletions
+6 -2
View File
@@ -4151,11 +4151,15 @@ class BasePlatformAdapter(ABC):
chat_id_alt=chat_id_alt, is_bot=is_bot, scope_id=_opt(scope_id),
guild_id=_opt(guild_id), parent_chat_id=_opt(parent_chat_id),
message_id=_opt(message_id))
profile, profile_route_rejected = None, False # profile from configured routes, if any
# Profile from configured routes, else the owning profile of a dedicated secondary bot (so no
# later ``source.profile``-less fallback can re-route the message through the default bot's routes).
owner_profile = getattr(self, "_owner_profile", None)
profile, profile_route_rejected = owner_profile, False
if self.gateway_runner is not None:
from gateway.profile_routing import ProfileRouteRejected
try:
profile = self.gateway_runner._profile_name_for_source(SessionSource(**fields))
profile = self.gateway_runner._profile_name_for_source(
SessionSource(**fields), adapter_profile=owner_profile) or owner_profile
except ProfileRouteRejected:
profile_route_rejected = True
except Exception:
+21 -1
View File
@@ -4,6 +4,11 @@ Matching priority, most specific first (``gateway.profile_routes`` in config.yam
platform + chat_id + thread_id (14) → platform + chat_id (6) → platform + guild_id (2)
→ default profile. For Discord threads/forum posts ``parent_chat_id`` carries the
direct parent, so a channel route also matches any thread/post under it.
A route applies only to messages received by the bot of its ``bot_profile`` (default: the
default profile's shared bot). Telegram DM ``chat_id == user_id`` for EVERY bot, so without
this a ``chat_id`` route meant for the shared bot would re-home the same user's DM with a
dedicated secondary bot into another profile (#104933).
"""
from __future__ import annotations
@@ -58,6 +63,7 @@ class ProfileRoute:
chat_id: Optional[str] = None
thread_id: Optional[str] = None
enabled: bool = True
bot_profile: Optional[str] = None # None = the default profile's bot
@property
def specificity(self) -> int:
@@ -67,14 +73,19 @@ class ProfileRoute:
def matches(
self, platform: str, guild_id: Optional[str] = None, chat_id: Optional[str] = None,
thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None,
adapter_profile: Optional[str] = None,
) -> bool:
"""True if every discriminator the route declares holds (AND).
``chat_id`` matches the channel directly or as the parent of a thread/forum post; WhatsApp
``chat_id`` also matches across number/JID/LID after the exact check (groups/broadcasts stay exact-only).
``adapter_profile`` is the profile owning the receiving bot (``None`` = default); it must equal
the route's ``bot_profile``.
"""
if not self.enabled or self.platform != platform:
return False
if _bot_profile_key(self.bot_profile) != _bot_profile_key(adapter_profile):
return False
if self.thread_id and self.thread_id != thread_id:
return False
if (
@@ -87,6 +98,12 @@ class ProfileRoute:
return not (self.guild_id and self.guild_id != guild_id)
def _bot_profile_key(name: Optional[str]) -> Optional[str]:
"""``None`` for the default profile, else the profile name (mirrors ``set_owner_profile``)."""
name = (name or "").strip()
return None if not name or name == "default" else name
def _coerce_route_id(value: Any) -> Optional[str]:
"""Normalize a route discriminator to str for strict equality matching.
@@ -139,6 +156,7 @@ def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRou
chat_id=_coerce_route_id(entry.get("chat_id")),
thread_id=_coerce_route_id(entry.get("thread_id")),
enabled=entry.get("enabled", True),
bot_profile=_bot_profile_key(entry.get("bot_profile")),
))
routes.sort(key=lambda r: r.specificity, reverse=True)
logger.debug("Loaded %d profile routes (most-specific-first)", len(routes))
@@ -148,9 +166,11 @@ def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRou
def match_profile_route(
routes: List[ProfileRoute], platform: str, guild_id: Optional[str] = None, chat_id: Optional[str] = None,
thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None,
adapter_profile: Optional[str] = None,
) -> Optional[ProfileRoute]:
"""Return the first (most specific) matching route, or None."""
for route in routes:
if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id):
if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id,
parent_chat_id=parent_chat_id, adapter_profile=adapter_profile):
return route
return None
+14 -5
View File
@@ -4278,23 +4278,32 @@ class GatewayRunner(
agent._last_flushed_db_idx = 0
agent._api_call_count = 0
def _profile_name_for_source(self, source: SessionSource) -> Optional[str]:
def _profile_name_for_source(
self, source: SessionSource, adapter_profile: Optional[str] = None,
) -> Optional[str]:
"""Resolve the profile name for an inbound source via configured routes (most specific wins).
``None`` = default/active profile. Gated on ``multiplex_profiles``, since the scoped run only
activates under multiplexing; otherwise keys would be profile-namespaced while the agent ran in
``agent:main``."""
``None`` = default/active profile (or, for a secondary adapter, its own profile — the caller
stamps it). Gated on ``multiplex_profiles``, since the scoped run only activates under
multiplexing; otherwise keys would be profile-namespaced while the agent ran in ``agent:main``.
``adapter_profile`` is the profile owning the receiving bot; only routes declaring it as
``bot_profile`` apply (#104933)."""
config = getattr(self, "config", None)
if not getattr(config, "multiplex_profiles", False):
return None
routes = getattr(config, "profile_routes", None)
if not routes:
return None
if adapter_profile is None:
# Sources built outside ``build_source`` may still carry the receiving adapter as provenance.
owner = self._transport_owner(source) if callable(getattr(source, "_transport_adapter_ref", None)) else None
if isinstance(owner, tuple):
adapter_profile = owner[1]
from gateway.profile_routing import ProfileRouteRejected, match_profile_route
try:
matched = match_profile_route(
routes, platform=source.platform.value, guild_id=getattr(source, "guild_id", None),
chat_id=source.chat_id, thread_id=getattr(source, "thread_id", None),
parent_chat_id=getattr(source, "parent_chat_id", None))
parent_chat_id=getattr(source, "parent_chat_id", None), adapter_profile=adapter_profile)
except Exception:
logger.warning(
"Profile route matching failed for %s/%s, falling back to default",
@@ -369,6 +369,29 @@ no route stay on the default/active profile. The routed profile gets the full
per-profile isolation described above (config, skills, memory, credentials,
session namespace). Routing works on every platform adapter, not just Discord.
A route applies only to messages received by the **default profile's bot**
unless it names another bot with `bot_profile: <profile>`. Telegram DMs use the
same `chat_id` for every bot (the user's id), so without this a
`chat_id` route meant for the shared bot would also capture that user's DMs
with a secondary profile's dedicated bot. Messages arriving at a secondary
profile's own bot stay in that profile:
```yaml
# Pin one user's DM with team_b's OWN bot to a third profile
- name: teamb-owner-dm
platform: telegram
bot_profile: team_b
chat_id: "72719239"
profile: ops-for-team-b
```
Authorization for a routed message is always decided by the **receiving bot's
profile** (its token and allowlist), including follow-ups sent while the agent
is busy and mid-turn checks such as `/topic` or `/stop`; the routed profile
itself needs no copy of the allowlist. A routed profile without a bot of its
own also receives background notifications (process completions, heartbeats,
async delegation results) through the shared bot after a gateway restart.
On WhatsApp and WhatsApp Cloud, a `chat_id` route matches across user-identity
forms: a bare phone number (`15551234567`), a JID
(`15551234567@s.whatsapp.net`), and a LID (`…@lid`) all refer to the same