diff --git a/docs/profile-routing.md b/docs/profile-routing.md index 9b0237f5c6f0e..3101200fe8946 100644 --- a/docs/profile-routing.md +++ b/docs/profile-routing.md @@ -105,6 +105,12 @@ If no route matches, the message uses the default/active profile. (`source.profile` → active profile → `default`) and the session is scoped per-profile, so each routed community gets isolated memory and conversation state. +Multiplexed secondary adapters are already bound to the profile that owns their +credential. That credential owner takes precedence over `profile_routes`; the +global routes apply to the primary/shared credential. This keeps several +independent Slack bots in one channel attached to their own profiles instead of +collapsing them all onto the channel's primary route. + Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`), every platform goes through this path — not just Discord. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 948a4da62948c..c8e030e60ee56 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -3002,6 +3002,11 @@ def set_status_text(self, chat_id: str, text: Optional[str]) -> None: # routing is platform-generic instead of Discord-only. gateway_runner = None # type: ignore[assignment] # set by gateway/run.py + # Multiplexed secondary adapters are created from one profile's own + # credential. Their inbound messages must stay bound to that profile even + # when a global channel route for the primary/shared credential matches. + owning_profile: Optional[str] = None + def __init__(self, config: PlatformConfig, platform: Platform): self.config = config self.platform = platform @@ -7051,11 +7056,13 @@ def build_source( if chat_topic is not None and not chat_topic.strip(): chat_topic = None - # Resolve profile from configured routes (None when no match / no routes) - profile = None + # A secondary adapter's credential owner is authoritative. Global + # profile routes are for the primary/shared credential and must not + # collapse several independently identified bots onto one profile. + profile = str(getattr(self, "owning_profile", "") or "").strip() or None profile_route_rejected = False runner = getattr(self, "gateway_runner", None) - if runner is not None: + if profile is None and runner is not None: from gateway.profile_routing import ProfileRouteRejected try: diff --git a/gateway/run.py b/gateway/run.py index f81e364aad34a..aa8ba91710763 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14897,6 +14897,7 @@ def _configure_profile_adapter( platform: Platform, ) -> None: """Install the profile-scoped handlers shared by startup and reconnect.""" + adapter.owning_profile = profile_name adapter.set_message_handler(self._make_profile_message_handler(profile_name)) adapter.set_fatal_error_handler( self._make_profile_fatal_error_handler(profile_name, platform) diff --git a/tests/gateway/test_profile_resolution.py b/tests/gateway/test_profile_resolution.py index 695b9c7b89436..2c7d34fd17d26 100644 --- a/tests/gateway/test_profile_resolution.py +++ b/tests/gateway/test_profile_resolution.py @@ -308,6 +308,58 @@ def test_discord_adapter_stamps_profile_and_scopes_key(self, mock_runner): # A default-profile key would land in agent:main — must differ. assert key != build_session_key(source, profile=None) + def test_secondary_adapter_credential_owner_wins_over_global_route( + self, mock_runner + ): + """A unique secondary bot identity must keep its owning profile. + + Global routes describe the primary/shared credential. Applying them to + every secondary adapter would make several Slack bots in one channel + all run the same routed profile despite having distinct credentials. + """ + mock_runner.config.profile_routes = [ + ProfileRoute( + name="shared-channel", + platform="slack", + profile="chief-of-staff", + chat_id="C_SHARED", + ) + ] + adapter = _stub_adapter(Platform.SLACK, mock_runner) + adapter.owning_profile = "software-engineer" + + source = adapter.build_source( + chat_id="C_SHARED", chat_type="group", user_id="U_OPERATOR" + ) + + assert source.profile == "software-engineer" + key = build_session_key(source, profile=source.profile) + assert key.startswith("agent:software-engineer:"), key + + def test_primary_adapter_without_owner_still_uses_global_route(self, mock_runner): + mock_runner.config.profile_routes = [ + ProfileRoute( + name="shared-channel", + platform="slack", + profile="chief-of-staff", + chat_id="C_SHARED", + ) + ] + adapter = _stub_adapter(Platform.SLACK, mock_runner) + + with patch( + "hermes_cli.profiles.profiles_to_serve", + return_value=[ + ("default", Path("/profiles/default")), + ("chief-of-staff", Path("/profiles/chief-of-staff")), + ], + ): + source = adapter.build_source( + chat_id="C_SHARED", chat_type="group", user_id="U_OPERATOR" + ) + + assert source.profile == "chief-of-staff" + @pytest.mark.asyncio async def test_adapter_drops_rejected_route_before_dispatch(self, mock_runner): mock_runner.config.multiplex_profile_allowlist = [] @@ -386,4 +438,3 @@ def test_routes_ignored_when_multiplex_off(self, mock_runner, discord_source): assert mock_runner._profile_name_for_source(discord_source) is None - diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 7feddd069a90e..8cae5ff8b43cc 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -250,6 +250,12 @@ communities share **one** bot token — for example one Discord bot serving many guilds — you can additionally route specific guilds/channels/threads to different profiles with `gateway.profile_routes`: +The credential binding wins for secondary profile adapters. In other words, a +Slack app/token configured on `software-engineer` always runs that profile, +even in a channel that the primary shared Slack app routes to another profile. +`profile_routes` select profiles for the primary/shared credential; they do not +rewrite the identity of independently credentialed peer bots. + ```yaml gateway: multiplex_profiles: true