From 449830f78314238ad0ee46fa88b12190e04da2b0 Mon Sep 17 00:00:00 2001 From: Andreas Hiltner Date: Thu, 2 Jul 2026 08:35:38 +0200 Subject: [PATCH 1/4] fix(gateway): route multiplex profile responses through correct adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 53 instances of self.adapters.get(source.platform) with self._adapter_for_source(source) in gateway/run.py. self.adapters is the default profile's adapter map. In multiplex mode, secondary profiles (lars, kira, jonas, caro) have their adapters in _profile_adapters[profile]. _adapter_for_source() (from authz_mixin.py) correctly resolves through _profile_adapters when source.profile is set. Without this fix, ALL response paths for secondary profiles — streaming, sending, media delivery, voice, typing indicators, queue operations, startup restore, and platform notices — route through the default profile's bot token instead of the profile's own token. Fixes: Multiplex profiles responding with wrong bot token on Telegram, Discord, and all other platforms. --- gateway/run.py | 124 ++++++++++++++++++++++++++----------------------- 1 file changed, 67 insertions(+), 57 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index b81e87dba641..fd6f9d6a42f4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5201,7 +5201,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session "Approval response via plain text: session=%s verb=%s args=%r", session_key, _verb, _normalized_args, ) - _adapter = self.adapters.get(event.source.platform) + _adapter = self._adapter_for_source(event.source) if _adapter and _reply: _text, _eph_ttl = _adapter._unwrap_ephemeral(_reply) if _text: @@ -6318,7 +6318,7 @@ async def _drain_startup_restore_queue(self) -> int: while queue: event = queue.pop(0) source = getattr(event, "source", None) - adapter = self.adapters.get(source.platform) if source is not None else None + adapter = self._adapter_for_source(source) if adapter is None: logger.debug( "Dropping startup-restore queued message: adapter unavailable for %s", @@ -6424,7 +6424,7 @@ def _schedule_resume_pending_sessions(self, platform=None) -> int: continue source = entry.origin - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter is None: logger.debug( "Skipping auto-resume for %s: adapter not ready for %s", @@ -8337,15 +8337,25 @@ async def _start_one_profile_adapters( "'open'." ) + # Secondary profiles must never bind ports — the default profile's + # listener serves every profile through /p//. Force-disable + # any port-binding platform that _apply_env_overrides may have enabled. + for pb_platform in _PORT_BINDING_PLATFORM_VALUES: + try: + plat = Platform(pb_platform) + except ValueError: + continue + if plat in profile_cfg.platforms: + profile_cfg.platforms[plat].enabled = False + profile_map = self._profile_adapters.setdefault(profile_name, {}) connected = 0 for platform, platform_config in profile_cfg.platforms.items(): if not platform_config.enabled: continue - # A secondary profile must NOT enable a port-binding platform: the - # default profile's listener already serves every profile via the - # /p// prefix, so a second bind can only collide. This is a - # config error, not a transient failure — fail fast and loud. + # The default profile owns the single shared HTTP listener and + # serves every profile through the /p// URL prefix. + # A secondary profile must NOT enable a port-binding platform. if platform.value in _PORT_BINDING_PLATFORM_VALUES: raise MultiplexConfigError( f"Profile '{profile_name}' enables the port-binding platform " @@ -8595,7 +8605,7 @@ def check( async def _deliver_platform_notice(self, source, content: str) -> None: """Deliver a setup/operational notice using platform-specific privacy rules.""" - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: return @@ -9070,7 +9080,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: has_media = bool(getattr(event, "media_urls", None)) if not queued_text and not has_media: return "Usage: /queue " - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: queued_event = MessageEvent( text=queued_text, @@ -9091,7 +9101,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: timestamp=event.timestamp, ) self._enqueue_fifo(_quick_key, queued_event, adapter) - depth = self._queue_depth(_quick_key, adapter=self.adapters.get(source.platform)) + depth = self._queue_depth(_quick_key, adapter=self._adapter_for_source(source)) if depth <= 1: return "Queued for the next turn." return f"Queued for the next turn. ({depth} queued)" @@ -9108,7 +9118,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: running_agent = self._running_agents.get(_quick_key) if running_agent is _AGENT_PENDING_SENTINEL: # Agent hasn't started yet — queue as turn-boundary fallback. - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: queued_event = MessageEvent( text=steer_text, @@ -9130,7 +9140,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: return f"⏩ Steer queued — arrives after the next tool call: '{preview}'" return "Steer rejected (empty payload)." # Running agent is missing or lacks steer() — fall back to queue. - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: queued_event = MessageEvent( text=steer_text, @@ -9255,7 +9265,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if event.message_type == MessageType.PHOTO: logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: merge_pending_message_event(adapter._pending_messages, _quick_key, event) return None @@ -9276,7 +9286,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: time.time() - _started_at, _quick_key, ) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: if self._busy_input_mode == "queue": self._enqueue_fifo(_quick_key, event, adapter) @@ -9299,7 +9309,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: return EphemeralReply("⚡ Force-stopped. The agent was still starting — session unlocked.") # Queue the message so it will be picked up after the # agent starts. - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: merge_pending_message_event( adapter._pending_messages, @@ -9538,7 +9548,7 @@ async def _do_reset(): else "Learning a skill from this conversation…" ) try: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: _ack_meta = self._thread_metadata_for_source(source) await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta) @@ -9592,7 +9602,7 @@ async def _do_reset(): _ack = getattr(_blueprint_result, "text", "") or "" if _ack: try: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: _ack_meta = self._thread_metadata_for_source(source) await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta) @@ -10219,7 +10229,7 @@ async def _prepare_inbound_message_text( # while allowing quiet STT for users who only want the agent to # receive the transcription. if _successful_transcripts and self._should_echo_stt_transcripts(): - _echo_adapter = self.adapters.get(source.platform) + _echo_adapter = self._adapter_for_source(source) _echo_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)) if _echo_adapter: for _tx in _successful_transcripts: @@ -10393,7 +10403,7 @@ async def _prepare_inbound_message_text( allowed_root=_msg_cwd, ) if _ctx_result.blocked: - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: await _adapter.send( source.chat_id, @@ -10634,7 +10644,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g and platform_name not in policy.notify_exclude_platforms ) if should_notify: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter: if reset_reason == "suspended": reason_text = "previous session was stopped or interrupted" @@ -11029,7 +11039,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g "configuration." ) try: - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter and source.chat_id: await _adapter.send(source.chat_id, _warn_msg, metadata=_hyg_meta) except Exception as _werr: @@ -11053,7 +11063,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g "check `auxiliary.compression.model` in config.yaml." ) try: - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter and source.chat_id: await _adapter.send(source.chat_id, _aux_msg, metadata=_hyg_meta) except Exception as _werr: @@ -11210,7 +11220,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # event so deferred post-delivery callbacks can be released by the # same run that registered them. self._bind_adapter_run_generation( - self.adapters.get(source.platform), + self._adapter_for_source(source), session_key, run_generation, ) @@ -11250,7 +11260,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Stop persistent typing indicator now that the agent is done try: - _typing_adapter = self.adapters.get(source.platform) + _typing_adapter = self._adapter_for_source(source) if _typing_adapter and hasattr(_typing_adapter, "stop_typing"): await _typing_adapter.stop_typing(source.chat_id) except Exception: @@ -11262,7 +11272,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _quick_key or "?", run_generation, ) - _stale_adapter = self.adapters.get(source.platform) + _stale_adapter = self._adapter_for_source(source) if getattr(type(_stale_adapter), "pop_post_delivery_callback", None) is not None: _stale_adapter.pop_post_delivery_callback( _quick_key, @@ -11772,7 +11782,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # users see the agent "stop responding without explanation." if agent_result.get("already_sent") and not agent_result.get("failed"): if response: - _media_adapter = self.adapters.get(source.platform) + _media_adapter = self._adapter_for_source(source) if _media_adapter: await self._deliver_media_from_response( response, event, _media_adapter, @@ -11783,7 +11793,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # still surface the runtime metadata on the final reply. if _footer_line: try: - _foot_adapter = self.adapters.get(source.platform) + _foot_adapter = self._adapter_for_source(source) if _foot_adapter: await _foot_adapter.send( source.chat_id, @@ -11799,7 +11809,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g except Exception as e: # Stop typing indicator on error too try: - _err_adapter = self.adapters.get(source.platform) + _err_adapter = self._adapter_for_source(source) if _err_adapter and hasattr(_err_adapter, "stop_typing"): await _err_adapter.stop_typing(source.chat_id) except Exception: @@ -12304,7 +12314,7 @@ def _get_goal_manager_for_event(self, event: "MessageEvent"): async def _send_goal_status_notice(self, source: Any, message: str) -> None: """Send a /goal judge status line back to the originating chat/thread.""" - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None)) return @@ -12331,7 +12341,7 @@ async def _defer_goal_status_notice_after_delivery(self, source: Any, message: s exactly this boundary; when unavailable, fall back to direct awaited delivery rather than silently dropping the notice. """ - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: logger.debug("goal continuation: no adapter for %s", getattr(source, "platform", None)) return @@ -12429,7 +12439,7 @@ async def _post_turn_goal_continuation( # Enqueue via the adapter's FIFO so a user message already in # flight preempts the continuation naturally. try: - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) _quick_key = self._session_key_for_source(source) if adapter and _quick_key: cont_event = MessageEvent( @@ -12462,7 +12472,7 @@ def _get_guild_id(event: MessageEvent) -> Optional[int]: async def _handle_voice_channel_join(self, event: MessageEvent) -> str: """Join the user's current Discord voice channel.""" - adapter = self.adapters.get(event.source.platform) + adapter = self._adapter_for_source(event.source) if not hasattr(adapter, "join_voice_channel"): return "Voice channels are not supported on this platform." @@ -12519,7 +12529,7 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str: async def _handle_voice_channel_leave(self, event: MessageEvent) -> str: """Leave the Discord voice channel.""" - adapter = self.adapters.get(event.source.platform) + adapter = self._adapter_for_source(event.source) guild_id = self._get_guild_id(event) if not guild_id or not hasattr(adapter, "leave_voice_channel"): @@ -12753,7 +12763,7 @@ async def _send_voice_reply(self, event: MessageEvent, text: str) -> None: logger.warning("Auto voice reply TTS failed: %s", result.get("error")) return - adapter = self.adapters.get(event.source.platform) + adapter = self._adapter_for_source(event.source) # If connected to a voice channel, play there instead of sending a file guild_id = self._get_guild_id(event) @@ -12931,7 +12941,7 @@ async def _run_background_task( media_urls = media_urls or [] media_types = media_types or [] - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: logger.warning("No adapter for platform %s in background task %s", source.platform, task_id) return @@ -13126,7 +13136,7 @@ def run_sync(): async def _get_telegram_topic_capabilities(self, source: SessionSource) -> dict: """Read Telegram private-topic capability flags via Bot API getMe.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) bot = getattr(adapter, "_bot", None) if bot is None or not hasattr(bot, "get_me"): return {"checked": False} @@ -13154,7 +13164,7 @@ def _field(name: str): async def _ensure_telegram_system_topic(self, source: SessionSource) -> None: """Create/pin the managed System topic after /topic activation when possible.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) if adapter is None or not source.chat_id: return @@ -13195,7 +13205,7 @@ async def _ensure_telegram_system_topic(self, source: SessionSource) -> None: async def _send_telegram_topic_setup_image(self, source: SessionSource) -> None: """Send the bundled BotFather Threads Settings screenshot when available.""" - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) if adapter is None or not source.chat_id or not hasattr(adapter, "send_image_file"): return image_path = Path(__file__).resolve().parent / "assets" / "telegram-botfather-threads-settings.jpg" @@ -13247,7 +13257,7 @@ async def _rename_telegram_topic_for_session_title( # Check the class, not the instance — getattr() on MagicMock # auto-creates attributes, so `hasattr(adapter, "_get_dm_topic_info")` # would return True for every test double. - adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None + adapter = self._adapter_for_source(source) if adapter is not None: get_info = getattr(type(adapter), "_get_dm_topic_info", None) if callable(get_info): @@ -13806,7 +13816,7 @@ async def _request_slash_confirm( # cannot race the send_slash_confirm return. _slash_confirm_mod.register(session_key, confirm_id, command, handler) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)) used_buttons = False @@ -14820,7 +14830,7 @@ async def _dequeue_pending_with_transcription( # Echo raw transcripts back to the user when configured so voice # interrupts feel identical to fresh voice messages. if successful_transcripts and self._should_echo_stt_transcripts(): - echo_adapter = self.adapters.get(source.platform) + echo_adapter = self._adapter_for_source(source) echo_meta = {"thread_id": source.thread_id} if source.thread_id else None if echo_adapter: for tx in successful_transcripts: @@ -15642,7 +15652,7 @@ async def _interrupt_and_clear_session( if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: running_agent.interrupt(interrupt_reason) self._invalidate_session_run_generation(session_key, reason=invalidation_reason) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter and hasattr(adapter, "interrupt_session_activity"): await adapter.interrupt_session_activity(session_key, source.chat_id) if adapter and hasattr(adapter, "get_pending_message"): @@ -16207,7 +16217,7 @@ def _run_still_current() -> bool: if _streaming_enabled: try: from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: _pause_typing_before_finalize = None if source.platform == Platform.TELEGRAM and hasattr(_adapter, "pause_typing_for_chat"): @@ -16258,7 +16268,7 @@ def _pause_typing_before_finalize( stream_task = asyncio.create_task(_stream_consumer.run()) # Send typing indicator - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: try: await _adapter.send_typing(source.chat_id, metadata=_thread_metadata) @@ -16698,7 +16708,7 @@ def voice_ack_callback(call_id, tool_name, args): _cleanup_progress = bool( resolve_display_setting(user_config, platform_key, "cleanup_progress") ) - _cleanup_adapter = self.adapters.get(source.platform) if _cleanup_progress else None + _cleanup_adapter = self._adapter_for_source(source) if _cleanup_progress else None if _cleanup_adapter is not None and ( type(_cleanup_adapter).delete_message is BasePlatformAdapter.delete_message ): @@ -16819,7 +16829,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non _code_block_full = None _code_block_short = None try: - _progress_adapter = self.adapters.get(source.platform) + _progress_adapter = self._adapter_for_source(source) except Exception: _progress_adapter = None if ( @@ -17009,7 +17019,7 @@ async def send_progress_messages(): if not progress_queue: return - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if not adapter: return @@ -17384,7 +17394,7 @@ def _event_callback_sync(event_type: str, context: dict) -> None: logger.debug("event_callback hook error: %s", _e) # Bridge sync status_callback → async adapter.send for context pressure - _status_adapter = self.adapters.get(source.platform) + _status_adapter = self._adapter_for_source(source) _status_chat_id = source.chat_id if source.platform == Platform.FEISHU and source.thread_id and event_message_id: # Feishu topics only keep messages inside the topic when they are @@ -17530,7 +17540,7 @@ def run_sync(): if _want_stream_deltas or _want_interim_consumer: try: from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if _adapter: _pause_typing_before_finalize = None if source.platform == Platform.TELEGRAM and hasattr(_adapter, "pause_typing_for_chat"): @@ -18698,7 +18708,7 @@ async def monitor_for_interrupt(): try: # Re-resolve adapter each iteration so reconnects don't # leave us holding a stale reference. - _adapter = self.adapters.get(source.platform) + _adapter = self._adapter_for_source(source) if not _adapter: continue # Check if adapter has a pending interrupt for this session. @@ -18793,7 +18803,7 @@ async def monitor_for_interrupt(): async def _notify_long_running(): if _NOTIFY_INTERVAL is None: return # Notifications disabled (gateway_notify_interval: 0) - _notify_adapter = self.adapters.get(source.platform) + _notify_adapter = self._adapter_for_source(source) if not _notify_adapter: return # Track the heartbeat message id so we can edit-in-place on @@ -18938,7 +18948,7 @@ def _stream_confirmed_final_delivery( # Backup interrupt check: if the monitor task died or # missed the interrupt, catch it here. if not _interrupt_detected.is_set() and session_key: - _backup_adapter = self.adapters.get(source.platform) + _backup_adapter = self._adapter_for_source(source) _backup_agent = agent_holder[0] if (_backup_adapter and _backup_agent and hasattr(_backup_adapter, 'has_pending_interrupt') @@ -18978,7 +18988,7 @@ def _stream_confirmed_final_delivery( if (not _warning_fired and _agent_warning is not None and _idle_secs >= _agent_warning): _warning_fired = True - _warn_adapter = self.adapters.get(source.platform) + _warn_adapter = self._adapter_for_source(source) if _warn_adapter: _elapsed_warn = int(_agent_warning // 60) or 1 _remaining_mins = int((_agent_timeout - _agent_warning) // 60) or 1 @@ -18998,7 +19008,7 @@ def _stream_confirmed_final_delivery( break # Backup interrupt check (same as unlimited path). if not _interrupt_detected.is_set() and session_key: - _backup_adapter = self.adapters.get(source.platform) + _backup_adapter = self._adapter_for_source(source) _backup_agent = agent_holder[0] if (_backup_adapter and _backup_agent and hasattr(_backup_adapter, 'has_pending_interrupt') @@ -19115,7 +19125,7 @@ def _stream_confirmed_final_delivery( # Check if we were interrupted OR have a queued message (/queue). result = result_holder[0] - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) # Get pending message from adapter. # Use session_key (not source.chat_id) to match adapter's storage keys. @@ -19246,7 +19256,7 @@ def _stream_confirmed_final_delivery( "queueing message instead of recursing.", _interrupt_depth, session_key, ) - adapter = self.adapters.get(source.platform) + adapter = self._adapter_for_source(source) if adapter and pending_event: merge_pending_message_event(adapter._pending_messages, session_key, pending_event) elif adapter and hasattr(adapter, 'queue_message'): @@ -19365,7 +19375,7 @@ def _stream_confirmed_final_delivery( # Restart typing indicator so the user sees activity while # the follow-up turn runs. The outer _process_message_background # typing task is still alive but may be stale. - _followup_adapter = self.adapters.get(source.platform) + _followup_adapter = self._adapter_for_source(source) if _followup_adapter: try: await _followup_adapter.send_typing( From 3c31ac1668fd7011278959c39b190b189d5be765 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:20:06 -0700 Subject: [PATCH 2/4] fix(gateway): fail-closed adapter resolution for unregistered secondary profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the routing sweep: when a stamped secondary profile has no _profile_adapters entry (adapter failed to connect / was refused), return None instead of falling back to the default profile's adapter — the fallback sends replies out the wrong bot, which is the exact leak class this cluster fixes. Also restores main's deliberate fail-fast on port-binding platforms in secondary profiles (the cherry-picked commit had softened it to silent force-disable). Co-authored-by: ManniBr --- gateway/authz_mixin.py | 6 +++++- gateway/run.py | 18 ++++-------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 86984913372b..f84ca259ccba 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -47,10 +47,14 @@ def _authorization_adapter( if not platform: return None profile_name = (profile or "").strip() or None - if profile_name: + if profile_name and profile_name != "default": profile_adapters = getattr(self, "_profile_adapters", None) or {} if profile_name in profile_adapters: return profile_adapters[profile_name].get(platform) + # Fail closed: a stamped secondary profile with no registry entry + # (e.g. its adapter failed to connect) must NOT fall back to the + # default profile's adapter — that sends replies out the wrong bot. + return None adapters = getattr(self, "adapters", None) or {} return adapters.get(platform) diff --git a/gateway/run.py b/gateway/run.py index fd6f9d6a42f4..51e33ceb4996 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8337,25 +8337,15 @@ async def _start_one_profile_adapters( "'open'." ) - # Secondary profiles must never bind ports — the default profile's - # listener serves every profile through /p//. Force-disable - # any port-binding platform that _apply_env_overrides may have enabled. - for pb_platform in _PORT_BINDING_PLATFORM_VALUES: - try: - plat = Platform(pb_platform) - except ValueError: - continue - if plat in profile_cfg.platforms: - profile_cfg.platforms[plat].enabled = False - profile_map = self._profile_adapters.setdefault(profile_name, {}) connected = 0 for platform, platform_config in profile_cfg.platforms.items(): if not platform_config.enabled: continue - # The default profile owns the single shared HTTP listener and - # serves every profile through the /p// URL prefix. - # A secondary profile must NOT enable a port-binding platform. + # A secondary profile must NOT enable a port-binding platform: the + # default profile's listener already serves every profile via the + # /p// prefix, so a second bind can only collide. This is a + # config error, not a transient failure — fail fast and loud. if platform.value in _PORT_BINDING_PLATFORM_VALUES: raise MultiplexConfigError( f"Profile '{profile_name}' enables the port-binding platform " From 1ee670b08dbfb5dbedbca0dd7d81d9f1f6f9db5d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:21:47 -0700 Subject: [PATCH 3/4] chore(release): map author emails for PR #56854/#57417 salvage --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 39ef393836a8..dca7e8d8780e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,8 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "derek2000139@qq.com": "derek2000139", # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update) + "AndreasHiltner@users.noreply.github.com": "AndreasHiltner", # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep) + "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) "jashlee+microsoft@microsoft.com": "s905060", # PR #57943 salvage (photon: auto-reinstall stale sidecar node_modules when lockfile is newer than npm's install marker; #59169) "lohinth25@proton.me": "l0h1nth", # PR #32210 salvage (mattermost: accept leading-space slash commands from mobile clients; #25184) "perkintahmaz50@gmail.com": "devatnull", # PR #58704 salvage (whatsapp: native Baileys polls, clarify-as-poll, location pins, structured quoted replies, PTT/audio split, bridge_helpers extraction) From cea79d615ba48bf655e9c22929d94277a5031b11 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:27:09 -0700 Subject: [PATCH 4/4] test(gateway): pin source.profile=None on MagicMock fixtures hitting _adapter_for_source The routing sweep sends these paths through _adapter_for_source, which reads source.profile. A bare MagicMock auto-attribute is truthy, so the fixtures looked like stamped secondary profiles and hit the new fail-closed branch. Real SessionSource.profile is None or str (AGENTS.md pitfall #17). --- tests/gateway/test_notice_rendering.py | 4 ++++ tests/gateway/test_queue_consumption.py | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/gateway/test_notice_rendering.py b/tests/gateway/test_notice_rendering.py index 89a5435a507a..3ba731f32e41 100644 --- a/tests/gateway/test_notice_rendering.py +++ b/tests/gateway/test_notice_rendering.py @@ -107,6 +107,10 @@ def _make_source(platform_value="telegram", chat_id="555", user_id="u1"): src.platform = plat src.chat_id = chat_id src.user_id = user_id + # Real SessionSource.profile is None (single-profile) or a str; a MagicMock + # auto-attribute would read as a truthy "stamped profile" and trip the + # fail-closed path in _adapter_for_source (see AGENTS.md pitfall #17). + src.profile = None return src diff --git a/tests/gateway/test_queue_consumption.py b/tests/gateway/test_queue_consumption.py index ec1b0dedc83d..857da98da71c 100644 --- a/tests/gateway/test_queue_consumption.py +++ b/tests/gateway/test_queue_consumption.py @@ -382,7 +382,9 @@ def _make_runner_and_adapter(self): return runner, adapter def _text_event(self, text: str) -> MessageEvent: - source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM) + # profile=None: a MagicMock auto-attribute reads as a truthy stamped + # profile and trips fail-closed adapter resolution (AGENTS.md #17). + source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM, profile=None) return MessageEvent( text=text, message_type=MessageType.TEXT, @@ -433,7 +435,7 @@ def test_photo_burst_still_merges_in_head_slot(self): runner, adapter = self._make_runner_and_adapter() session_key = "telegram:user:burst" - source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM) + source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM, profile=None) for i in range(3): runner._queue_or_replace_pending_event( session_key,