{"repo":"NousResearch/hermes-agent","public_id":"52563","display_label":"Feishu wrongly flagged as port-binding in multiplex gateway","summary":"Multiple issues and fixes address Feishu being incorrectly classified as a port-binding platform, which prevented it from working in multiplexed gateway profiles. Fixes remove Feishu from the port-binding set and add YAML-based adapter config seeding for multi-profile support.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":65700},"resolution":{"state":"partially_resolved","by":"pr-65700"},"freshness":{"state":"verified","last_verified_at":"2026-07-16T14:17:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3573333333333333},"fix_edges":[{"issue_number":52563,"pr_number":65700,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":52796,"pr_number":65700,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":57896,"pr_number":65700,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":60726,"pr_number":65700,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":61276,"pr_number":65700,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62791,"pr_number":65700,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":52563,"title":"Feishu misclassified as port-binding platform, breaking multiplexed Feishu profiles","url":null,"state":"closed"},{"kind":"issue","number":57896,"title":"Multiplexing gateway: feishu websocket mode should not be treated as port-binding","url":null,"state":"closed"},{"kind":"pr","number":52587,"title":"fix(gateway): allow Feishu websocket mode in multiplex profiles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65700","number":65700},{"kind":"pr","value":"#62803","number":62803},{"kind":"pr","value":"#52563","number":52563}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58016,"title":"fix(gateway): allow feishu websocket mode in multiplexed profiles (#57896)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65700","number":65700},{"kind":"pr","value":"#52587","number":52587}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":61437,"title":"fix(gateway): remove feishu from port-binding platform set (fixes #52563)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52563","number":52563}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":65700,"title":"fix(gateway): multiplex wave 2 — port-binding degradation, dashboard guard, scoped picker//profile/api_server (7-PR cluster salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/api_server.py","gateway/run.py","gateway/slash_commands.py","hermes_cli/web_server.py","scripts/release.py","tests/gateway/test_api_server_multiplex_secret_scope.py","tests/gateway/test_model_picker_persist.py","tests/gateway/test_multiplex_adapter_registry.py","tests/gateway/test_multiplex_profile_authz.py","tests/gateway/test_status_command.py","tests/hermes_cli/test_web_server_messaging_profiles.py","website/docs/user-guide/multi-profile-gateways.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -313,6 +313,50 @@ def _scan_bundled_plugin_platforms(cls) -> set:\n _BUILTIN_PLATFORM_VALUES = frozenset(m.value for m in Platform.__members__.values())\n \n \n+# Platforms that bind a host TCP port (HTTP/webhook listeners). In a profile\n+# multiplexer the default profile owns the single shared listener and serves\n+# every profile through the /p/<profile>/ URL prefix, so a SECONDARY profile\n+# enabling one of these is always a misconfiguration: it would try to bind a\n+# port already held by the default's listener. Single source of truth for\n+# both the gateway's fail-fast startup validation (gateway/run.py) and the\n+# dashboard's pre-write mutation validation (hermes_cli/web_server.py) so\n+# the two policies cannot drift. Stored as platform .value strings.\n+PORT_BINDING_PLATFORM_VALUES = frozenset({\n+    \"webhook\",\n+    \"api_server\",\n+    \"msgraph_webhook\",\n+    \"feishu\",\n+    \"wecom_callback\",\n+    \"bluebubbles\",\n+    \"sms\",\n+    \"whatsapp_cloud\",\n+    \"line\",\n+})\n+\n+# Platforms whose port-binding status depends on connection mode. Feishu in\n+# websocket mode (its default) uses an outbound long connection — no listener.\n+# Only webhook/callback mode binds a port. Maps platform value → the mode\n+# value that actually binds (#52563).\n+PORT_BINDING_CONDITIONAL_MODES: dict[str, str] = {\n+    \"feishu\": \"webhook\",\n+}\n+\n+\n+def platform_binds_port(platform_value: str, extra: Optional[dict] = None) -> bool:\n+    \"\"\"Return True when *platform_value* actually binds a port for *extra* config.\n+\n+    Mode-conditional platforms (Feishu) only bind in their listener mode;\n+    everything else in ``PORT_BINDING_PLATFORM_VALUES`` always binds.\n+    \"\"\"\n+    if platform_value not in PORT_BINDING_PLATFORM_VALUES:\n+        return False\n+    expected_mode = PORT_BINDING_CONDITIONAL_MODES.get(platform_value)\n+    if expected_mode is not None:\n+        actual = str((extra or {}).get(\"connection_mode\", \"websocket\")).strip().lower()\n+        return actual == expected_mode\n+    return True\n+\n+\n @dataclass\n class HomeChannel:\n     \"\"\""]},{"file":"gateway/platforms/api_server.py","hunks":["@@ -1284,8 +1284,27 @@ def _resolve_request_profile(self, request: \"web.Request\"):\n \n     @staticmethod\n     def _profile_scope(profile: Optional[str]):\n-        \"\"\"Enter the multiplex profile runtime scope, or a no-op when unset.\"\"\"\n+        \"\"\"Enter the multiplex profile runtime scope, or a no-op when unset.\n+\n+        When no ``/p/<profile>/`` prefix was given AND multiplexing is active,\n+        enter the DEFAULT profile's scope instead of a no-op: api_server is a\n+        port-binding platform that lives on the default profile, and with\n+        multiplex fail-closed ``get_secret`` active, an unscoped agent run\n+        raises ``UnscopedSecretError`` on its first credential read (#61276).\n+        Single-profile gateways keep the no-op — ``get_secret`` falls through\n+        to ``os.environ`` there, unchanged.\n+        \"\"\"\n         if not profile:\n+            try:\n+                from agent.secret_scope import is_multiplex_active\n+\n+                if is_multiplex_active():\n+                    from gateway.run import _profile_runtime_scope\n+                    from hermes_constants import get_hermes_home\n+\n+                    return _profile_runtime_scope(get_hermes_home())\n+            except Exception:\n+                pass\n             return nullcontext()\n         from gateway.run import _profile_runtime_scope\n         from hermes_cli.profiles import get_profile_dir"]},{"file":"gateway/run.py","hunks":["@@ -1411,32 +1411,29 @@ def _current_max_iterations() -> int:\n # Platforms that bind a host TCP port (HTTP/webhook listeners). In a profile\n # multiplexer the default profile owns the single shared listener and serves\n # every profile through the /p/<profile>/ URL prefix, so a SECONDARY profile\n-# enabling one of these is always a misconfiguration: it would try to bind a\n-# port already held by the default's listener. We hard-error on it rather than\n-# silently dropping the adapter (see _start_one_profile_adapters).\n-# Stored as platform .value strings since the Platform enum is imported below.\n-_PORT_BINDING_PLATFORM_VALUES = frozenset({\n-    \"webhook\",\n-    \"api_server\",\n-    \"msgraph_webhook\",\n-    \"feishu\",\n-    \"wecom_callback\",\n-    \"bluebubbles\",\n-    \"sms\",\n-    \"whatsapp_cloud\",\n-    \"line\",\n-})\n+# enabling one of these is always a misconfiguration. We skip that secondary\n+# profile (SecondaryPortBindingConfigError) so a single bad profile cannot\n+# take down the whole multiplexer. The set lives in gateway.config so the\n+# dashboard's pre-write validation enforces the same policy.\n+from gateway.config import (\n+    PORT_BINDING_PLATFORM_VALUES as _PORT_BINDING_PLATFORM_VALUES,\n+    platform_binds_port as _platform_binds_port,\n+)\n \n \n class MultiplexConfigError(RuntimeError):\n-    \"\"\"A profile multiplexer config is invalid (fail-fast at startup).\n+    \"\"\"A profile multiplexer config is invalid.\n \n-    Distinct from a transient adapter-connect failure: a transient error is\n-    logged and the gateway stays alive to retry, but a config error means the\n-    operator must fix config.yaml, so it aborts startup cleanly.\n+    Distinct from a transient adapter-connect failure: a config error means the\n+    operator must fix config.yaml. Fatal configuration errors propagate to the\n+    startup guard instead of being treated as retryable adapter noise.\n     \"\"\"\n \n \n+class SecondaryPortBindingConfigError(MultiplexConfigError):\n+    \"\"\"A secondary profile conflicts with the multiplexer's shared listener.\"\"\"\n+\n+\n @_contextmanager\n def _profile_runtime_scope(profile_home: \"Path\"):\n     \"\"\"Scope config/skills/memory AND credentials to a profile for one turn.","@@ -8754,10 +8751,13 @@ async def _start_secondary_profile_adapters(self) -> int:\n                 connected += await self._start_one_profile_adapters(\n                     profile_name, profile_home, claimed\n                 )\n+            except SecondaryPortBindingConfigError as e:\n+                logger.warning(\n+                    \"Skipping secondary profile '%s' due to port-binding config error: %s\",\n+                    profile_name,\n+                    e,\n+                )\n             except MultiplexConfigError:\n-                # Config error (e.g. a secondary profile binding a port) is not\n-                # transient — propagate so startup aborts cleanly instead of\n-                # limping along with a half-configured multiplexer.\n                 raise\n             except Exception as e:\n                 logger.error(","@@ -8800,6 +8800,23 @@ async def _start_one_profile_adapters(\n                 \"'open'.\"\n             )\n \n+        port_binding_platforms = sorted(\n+            platform.value\n+            for platform, platform_config in profile_cfg.platforms.items()\n+            if platform_config.enabled\n+            and _platform_binds_port(platform.value, platform_config.extra)\n+        )\n+        if port_binding_platforms:\n+            joined = \", \".join(port_binding_platforms)\n+            raise SecondaryPortBindingConfigError(\n+                f\"Profile '{profile_name}' enables port-binding platform(s) \"\n+                f\"{joined}, but gateway.multiplex_profiles is on. The default \"\n+                f\"profile owns the single shared HTTP listener and serves every \"\n+                f\"profile through the /p/{profile_name}/ URL prefix. Remove \"\n+                f\"these platform entries from profile '{profile_name}'s config.yaml \"\n+                f\"or configure them only on the default profile.\"\n+            )\n+\n         profile_map = self._profile_adapters.setdefault(profile_name, {})\n         connected = 0\n         for platform, platform_config in profile_cfg.platforms.items():","@@ -8813,21 +8830,6 @@ async def _start_one_profile_adapters(\n                 and platform is Platform.RELAY\n             ):\n                 continue\n-            # A secondary profile must NOT enable a port-binding platform: the\n-            # default profile's listener already serves every profile via the\n-            # /p/<profile>/ prefix, so a second bind can only collide. This is a\n-            # config error, not a transient failure — fail fast and loud.\n-            if platform.value in _PORT_BINDING_PLATFORM_VALUES:\n-                raise MultiplexConfigError(\n-                    f\"Profile '{profile_name}' enables the port-binding platform \"\n-                    f\"'{platform.value}', but gateway.multiplex_profiles is on. The \"\n-                    f\"default profile owns the single shared HTTP listener and \"\n-                    f\"serves every profile through the /p/{profile_name}/ URL \"\n-                    f\"prefix — a secondary profile cannot bind its own port. \"\n-                    f\"Remove platforms.{platform.value} from profile \"\n-                    f\"'{profile_name}'s config.yaml (configure it only on the \"\n-                    f\"default profile).\"\n-                )\n             try:\n                 with _profile_runtime_scope(profile_home):\n                     adapter = self._create_adapter(platform, platform_config)","@@ -8873,7 +8875,9 @@ async def _start_one_profile_adapters(\n             adapter.set_session_store(self.session_store)\n             adapter.set_busy_session_handler(self._handle_active_session_busy_message)\n             adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)\n-            adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform))\n+            adapter.set_authorization_check(\n+                self._make_adapter_auth_check(adapter.platform, profile_name=profile_name)\n+            )\n             adapter._busy_text_mode = self._busy_text_mode\n \n             try:","@@ -9090,6 +9094,7 @@ def _create_adapter(\n     def _make_adapter_auth_check(\n         self,\n         platform: Platform,\n+        profile_name: Optional[str] = None,\n     ) -> Callable[[str, Optional[str], Optional[str]], bool]:\n         \"\"\"Build a platform-bound auth callback for adapter use.\n ","@@ -9102,6 +9107,10 @@ def _make_adapter_auth_check(\n         The returned callback delegates to :meth:`_is_user_authorized` so the\n         full auth chain — platform allowlists, group allowlists, pairing\n         store, allow-all flags — stays the single source of truth.\n+\n+        ``profile_name`` binds the callback to the secondary adapter's own\n+        multiplex profile, so its ``SessionSource`` resolves that profile's\n+        secret scope instead of falling back to the active profile.\n         \"\"\"\n         def check(\n             user_id: str,","@@ -9115,6 +9124,7 @@ def check(\n                 chat_id=chat_id or \"\",\n                 chat_type=chat_type or \"group\",\n                 user_id=user_id,\n+                profile=profile_name,\n             )\n             return self._is_user_authorized(source)\n         return check"]},{"file":"gateway/slash_commands.py","hunks":["@@ -330,12 +330,44 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer\n         return EphemeralReply(f\"{header}{_tip_line}\")\n \n     async def _handle_profile_command(self, event: MessageEvent) -> str:\n-        \"\"\"Handle /profile — show active profile name and home directory.\"\"\"\n+        \"\"\"Handle /profile — show the profile serving this source and its home.\n+\n+        On a multiplexed gateway the process-level active profile is always\n+        the multiplexer's own (usually ``default``), so reporting it would\n+        answer \"default\" in every chat regardless of which profile actually\n+        serves the room/channel (``source.profile`` — stamped by the\n+        ``/p/<profile>/`` URL prefix, a per-credential adapter, or a room→\n+        profile map). When ``multiplex_profiles`` is on, report the stamped\n+        profile and, like the scoped /reset banner (#59003), resolve the\n+        displayed home under that profile's runtime scope. When multiplexing\n+        is off (the default) the stamp is ignored — mirroring the gating in\n+        ``_run_agent`` and ``_reset_notice_session_info`` — and the command\n+        reports the active profile and default home, byte-identical to before.\n+        \"\"\"\n         from hermes_constants import display_hermes_home\n         from hermes_cli.profiles import get_active_profile_name\n \n-        display = display_hermes_home()\n-        profile_name = get_active_profile_name()\n+        multiplexed = getattr(\n+            getattr(self, \"config\", None), \"multiplex_profiles\", False\n+        )\n+        source = getattr(event, \"source\", None)\n+\n+        profile_name = \"\"\n+        if multiplexed:\n+            profile_name = (getattr(source, \"profile\", \"\") or \"\").strip()\n+        profile_name = profile_name or get_active_profile_name()\n+\n+        if multiplexed:\n+            try:\n+                from gateway.run import _profile_runtime_scope\n+\n+                profile_home = self._resolve_profile_home_for_source(source)\n+                with _profile_runtime_scope(profile_home):\n+                    display = display_hermes_home()\n+            except Exception:\n+                display = display_hermes_home()\n+        else:\n+            display = display_hermes_home()\n \n         lines = [\n             t(\"gateway.profile.header\", profile=profile_name),","@@ -1438,6 +1470,12 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n         from hermes_cli.providers import get_label\n \n         raw_args = event.get_command_args().strip()\n+        source = event.source\n+        _command_profile_home = None\n+        if getattr(getattr(self, \"config\", None), \"multiplex_profiles\", False):\n+            _command_profile_home = getattr(\n+                self, \"_resolve_profile_home_for_source\"\n+            )(source)\n \n         # Parse --provider, --global, --session, and --refresh flags\n         (","@@ -1464,7 +1502,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n         current_api_key = \"\"\n         user_provs = None\n         custom_provs = None\n-        config_path = _hermes_home / \"config.yaml\"\n+        config_path = (_command_profile_home or _hermes_home) / \"config.yaml\"\n         try:\n             cfg = _load_gateway_config()\n             if cfg:","@@ -1482,9 +1520,8 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n         except Exception:\n             pass\n \n-        # Check for session override\n-        source = event.source\n-        # Normalize the source the same way a normal message turn does\n+        # Check for session override. Normalize the source the same way a normal\n+        # message turn does\n         # (Telegram DM topic recovery) before deriving the override key, so\n         # the override is stored under the key the next message turn reads\n         # (#30479).","@@ -1500,7 +1537,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n         # No args: show interactive picker (Telegram/Discord) or text list\n         if not model_input and not explicit_provider:\n             # Try interactive picker if the platform supports it\n-            adapter = self.adapters.get(source.platform)\n+            adapter = getattr(self, \"_adapter_for_source\")(source)\n             has_picker = (\n                 adapter is not None\n                 and getattr(type(adapter), \"send_model_picker\", None) is not None","@@ -1533,8 +1570,9 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:\n                     _cur_provider = current_provider\n                     _cur_base_url = current_base_url\n                     _cur_api_key = [redacted:secret-assignment]\n+                    _picker_profile_home = _command_profile_home\n \n-                    async def _on_model_selected(\n+                    async def _on_model_selected_scoped(\n                         _chat_id: str, model_id: str, provider_slug: str\n                     ) -> str:\n                         \"\"\"Perform the model switch and return confirmation text.\"\"\"","@@ -1733,6 +1771,20 @@ async def _on_model_selected(\n                             lines.append(t(\"gateway.model.session_only_hint\"))\n                         return \"\\n\".join(lines)\n \n+                    async def _on_model_selected(\n+                        _chat_id: str, model_id: str, provider_slug: str\n+                    ) -> str:\n+                        if _picker_profile_home is None:\n+                            return await _on_model_selected_scoped(\n+                                _chat_id, model_id, provider_slug\n+                            )\n+                        from gateway.run import _profile_runtime_scope\n+\n+                        with _profile_runtime_scope(_picker_profile_home):\n+                            return await _on_model_selected_scoped(\n+                                _chat_id, model_id, provider_slug\n+                            )\n+\n                     metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))\n                     result = await adapter.send_model_picker(\n                         chat_id=source.chat_id,"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -2467,7 +2467,7 @@ async def git_branch_switch_route(body: GitBranchSwitchBody):\n \n # Host TCP ports each port-binding gateway platform listens on, as\n # ``platform-name -> (config port key, adapter default)``.  Mirrors\n-# ``_PORT_BINDING_PLATFORM_VALUES`` in gateway/run.py and each adapter's\n+# ``PORT_BINDING_PLATFORM_VALUES`` in gateway/config.py and each adapter's\n # DEFAULT_PORT / DEFAULT_WEBHOOK_PORT constant.  Used only for the dashboard's\n # gateway-topology readout — best-effort display data, not a bind source.\n _PORT_BINDING_PLATFORM_PORTS: Dict[str, Tuple[str, int]] = {","@@ -8088,6 +8088,57 @@ async def get_messaging_platforms(profile: Optional[str] = None):\n         }\n \n \n+def _multiplex_port_binding_conflict(\n+    platform_id: str, requested_profile: Optional[str]\n+) -> Optional[str]:\n+    \"\"\"Reason enabling ``platform_id`` on the target profile would break a\n+    multiplexed gateway, or ``None`` when the change is allowed.\n+\n+    Mirrors the gateway's startup rule (``_start_one_profile_adapters`` in\n+    gateway/run.py): with ``gateway.multiplex_profiles`` on, the default\n+    profile owns the single shared HTTP listener and serves every profile via\n+    the ``/p/<profile>/`` prefix, so a SECONDARY profile must never enable a\n+    port-binding platform. Without this pre-write check the dashboard happily\n+    persisted the invalid config and the shared gateway died with\n+    ``MultiplexConfigError`` on its next start — for ALL profiles. Only\n+    *enabling* is blocked; disabling/clearing stays allowed so users can\n+    repair an already-invalid profile.\n+    \"\"\"\n+    from gateway.config import PORT_BINDING_PLATFORM_VALUES, load_gateway_config\n+\n+    if platform_id not in PORT_BINDING_PLATFORM_VALUES:\n+        return None\n+\n+    requested = (requested_profile or \"\").strip()\n+    if not requested or requested.lower() == \"current\":\n+        from hermes_cli.profiles import get_active_profile_name\n+\n+        # The dashboard's own profile. \"custom\" (an unrecognized HERMES_HOME)\n+        # is outside the profiles tree, so a multiplexed gateway never serves\n+        # it — nothing to guard.\n+        target = get_active_profile_name()\n+    else:\n+        _resolve_profile_dir(requested)  # same 400/404 as _profile_scope\n+        target = requested\n+    if target in (\"default\", \"custom\"):\n+        return None\n+\n+    # The multiplex flag that matters is the one the shared gateway reads at\n+    # startup: the DEFAULT profile's gateway config (plus the process-wide\n+    # GATEWAY_MULTIPLEX_PROFILES override, which load_gateway_config applies).\n+    with _config_profile_scope(\"default\"):\n+        if not load_gateway_config().multiplex_profiles:\n+            return None\n+\n+    return (\n+        f\"Cannot enable '{platform_id}' on profile '{target}': it binds its \"\n+        \"own listener port, and gateway.multiplex_profiles is on, so the \"\n+        \"default profile owns the single shared HTTP listener for every \"\n+        \"profile. Configure this channel on the default profile instead \"\n+        \"(disabling or clearing it here is still allowed).\"\n+    )\n+\n+\n @app.put(\"/api/messaging/platforms/{platform_id}\")\n async def update_messaging_platform(\n     platform_id: str, body: MessagingPlatformUpdate, profile: Optional[str] = None","@@ -8098,6 +8149,20 @@ async def update_messaging_platform(\n             status_code=404, detail=f\"Unknown messaging platform: {platform_id}\"\n         )\n \n+    target_profile = body.profile or profile\n+    if body.enabled:\n+        conflict = _multiplex_port_binding_conflict(platform_id, target_profile)\n+        if conflict:\n+            # Reject BEFORE any .env/config.yaml write so the profile stays\n+            # loadable by the multiplexed gateway.\n+            _log.info(\n+                \"Rejected messaging platform update: platform=%s profile=%s \"\n+                \"(multiplex port-binding conflict)\",\n+                platform_id,\n+                target_profile or \"current\",\n+            )\n+            raise HTTPException(status_code=409, detail=conflict)\n+\n     allowed_env = set(entry[\"env_vars\"])\n     try:\n         with _profile_scope(body.profile or profile):","@@ -8123,6 +8188,16 @@ async def update_messaging_platform(\n             if body.enabled is not None:\n                 _write_platform_enabled(platform_id, body.enabled)\n \n+        # Audit trail for channel config mutations: names only, never values.\n+        _log.info(\n+            \"Messaging platform updated: platform=%s profile=%s enabled=%s \"\n+            \"env_keys=%s cleared_keys=%s\",\n+            platform_id,\n+            target_profile or \"current\",\n+            body.enabled,\n+            sorted(body.env),\n+            sorted(body.clear_env),\n+        )\n         return {\"ok\": True, \"platform\": platform_id}\n     except HTTPException:\n         raise"]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"karfly\",  # PR #64012 salvage (gateway: surface extended reasoning efforts)\n+    \"[redacted:email]\": \"rlaehddus302\",  # PR #61985 salvage (gateway: secondary-adapter auth callback profile)\n     \"[redacted:email]\": \"aguung\",  # PR #64461 salvage (gateway: multiplex secret_scope for authz/Slack/webhooks)\n     \"[redacted:email]\": \"jtstothard\",  # PR #63256 salvage (gateway: multiplex secondary adapter config validation)\n     \"[redacted:email]\": \"SAMBAS123\",  # PR #64986 salvage (gateway: multiplex primary bot token scope)"]},{"file":"tests/gateway/test_api_server_multiplex_secret_scope.py","hunks":["@@ -0,0 +1,81 @@\n+\"\"\"Regression for #61276: api_server agent entry under multiplex isolation.\n+\n+When gateway.multiplex_profiles is on, get_secret fails closed without a\n+profile secret scope. Requests with a ``/p/<profile>/`` prefix are scoped by\n+``_profile_scope(profile)``, but plain requests on the default listener used\n+to get ``nullcontext()`` — so agent runs crashed with UnscopedSecretError on\n+their first credential read (e.g. OPENROUTER_BASE_URL). ``_profile_scope``\n+now enters the DEFAULT profile's runtime scope when multiplex is active and\n+no profile was requested.\n+\n+Adapted from PR #61283 by @giggling-ginger (originally targeting a\n+pre-``_profile_scope`` helper); no live gateway or network.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import pytest\n+\n+from agent import secret_scope as ss\n+from gateway.config import PlatformConfig\n+from gateway.platforms.api_server import APIServerAdapter\n+\n+\n[redacted:email](autouse=True)\n+def _reset_multiplex():\n+    ss.set_multiplex_active(False)\n+    yield\n+    ss.set_multiplex_active(False)\n+\n+\n[redacted:email]\n+def adapter():\n+    return APIServerAdapter(PlatformConfig(enabled=True))\n+\n+\n+class TestProfileScopeDefaultFallback:\n+    def test_noop_when_multiplex_off(self, adapter, monkeypatch):\n+        monkeypatch.setenv(\"OPENROUTER_BASE_URL\", \"https://from-environ.example/v1\")\n+        with adapter._profile_scope(None):\n+            # Legacy single-profile path: unscoped get_secret reads os.environ.\n+            assert ss.get_secret(\"OPENROUTER_BASE_URL\") == \"https://from-environ.example/v1\"\n+        assert ss.current_secret_scope() is None\n+\n+    def test_default_scope_installed_under_multiplex(self, adapter, tmp_path, monkeypatch):\n+        \"\"\"No /p/ prefix + multiplex active → default profile scope, not nullcontext.\"\"\"\n+        (tmp_path / \".env\").write_text(\n+            \"OPENROUTER_BASE_URL=https://openrouter.ai/api/v1\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        monkeypatch.setattr(\n+            \"hermes_constants.get_hermes_home\",\n+            lambda: tmp_path,\n+        )\n+        monkeypatch.setenv(\"OPENROUTER_BASE_URL\", \"https://leak.example/v1\")\n+        ss.set_multiplex_active(True)\n+\n+        with adapter._profile_scope(None):\n+            assert ss.current_secret_scope() is not None\n+            # Profile .env wins; process env must not leak through.\n+            assert ss.get_secret(\"OPENROUTER_BASE_URL\") == \"https://openrouter.ai/api/v1\"\n+\n+        # Scope torn down; fail-closed behavior restored outside.\n+        assert ss.current_secret_scope() is None\n+        with pytest.raises(ss.UnscopedSecretError):\n+            ss.get_secret(\"OPENROUTER_BASE_URL\")\n+\n+    def test_named_profile_scope_still_wins(self, adapter, tmp_path, monkeypatch):\n+        \"\"\"A /p/<profile>/ request keeps resolving that profile's scope.\"\"\"\n+        profile_home = tmp_path / \"profiles\" / \"worker\"\n+        profile_home.mkdir(parents=True)\n+        (profile_home / \".env\").write_text(\n+            \"OPENROUTER_BASE_URL=https://worker.example/v1\\n\", encoding=\"utf-8\"\n+        )\n+        monkeypatch.setattr(\n+            \"hermes_cli.profiles.get_profile_dir\", lambda name: profile_home\n+        )\n+        ss.set_multiplex_active(True)\n+\n+        with adapter._profile_scope(\"worker\"):\n+            assert ss.get_secret(\"OPENROUTER_BASE_URL\") == \"https://worker.example/v1\"\n+        assert ss.current_secret_scope() is None"]},{"file":"tests/gateway/test_model_picker_persist.py","hunks":["@@ -80,6 +80,22 @@ def _fake_switch_result():\n     )\n \n \n+def _stub_picker_dependencies(monkeypatch):\n+    monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.list_picker_providers\",\n+        lambda **kw: [{\"slug\": \"openrouter\", \"name\": \"OpenRouter\", \"models\": [\"gpt-5.5\"]}],\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.switch_model\",\n+        lambda **kw: _fake_switch_result(),\n+    )\n+    monkeypatch.setattr(\n+        \"hermes_cli.model_switch.resolve_display_context_length\",\n+        lambda *a, **k: 272000,\n+    )\n+\n+\n def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value):\n     \"\"\"Write a config.yaml with the given ``model:`` value and stub heavy bits.\"\"\"\n     import gateway.run as gateway_run","@@ -93,35 +109,41 @@ def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value):\n     )\n \n     monkeypatch.setattr(gateway_run, \"_hermes_home\", hermes_home)\n-    monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n-    # The picker-setup path calls list_picker_providers, which otherwise hits\n-    # the network (OpenRouter model catalog). Stub it to a minimal list — these\n-    # tests capture and fire the on_model_selected callback and don't assert on\n-    # picker contents. The handler imports it as a local alias at call time, so\n-    # patching the source-module attribute takes effect.\n+    _stub_picker_dependencies(monkeypatch)\n+    # save_config writes to ``get_hermes_home() / config.yaml`` — point it here.\n+    monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n+    monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n+    return cfg_path\n+\n+\n+def _make_named_runner(monkeypatch, default_adapter, named_adapter, named_home):\n+    runner = _make_runner(default_adapter)\n     monkeypatch.setattr(\n-        \"hermes_cli.model_switch.list_picker_providers\",\n-        lambda **kw: [{\"slug\": \"openrouter\", \"name\": \"OpenRouter\", \"models\": [\"gpt-5.5\"]}],\n+        runner, \"config\", types.SimpleNamespace(multiplex_profiles=True), raising=False\n     )\n-    # switch_model is imported as a local alias inside the handler\n-    # (`from hermes_cli.model_switch import switch_model as _switch_model`),\n-    # so patching the source-module attribute takes effect at call time.\n     monkeypatch.setattr(\n-        \"hermes_cli.model_switch.switch_model\",\n-        lambda **kw: _fake_switch_result(),\n+        runner,\n+        \"_profile_adapters\",\n+        {\"named\": {Platform.TELEGRAM: named_adapter}},\n+        raising=False,\n     )\n-    # The confirmation builder resolves context length for display, which\n-    # otherwise makes real outbound HTTP calls (Ollama /api/show + the\n-    # OpenRouter models catalog). Stub it — these tests don't assert on the\n-    # displayed context, and the closure imports it lazily from this module.\n     monkeypatch.setattr(\n-        \"hermes_cli.model_switch.resolve_display_context_length\",\n-        lambda *a, **k: 272000,\n+        runner, \"_resolve_profile_home_for_source\", lambda source: named_home\n+    )\n+    return runner\n+\n+\n+def _named_event(args):\n+    return MessageEvent(\n+        text=f\"/model {args}\".rstrip(),\n+        message_type=MessageType.TEXT,\n+        source=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"named-chat\",\n+            chat_type=\"dm\",\n+            profile=\"named\",\n+        ),\n     )\n-    # save_config writes to ``get_hermes_home() / config.yaml`` — point it here.\n-    monkeypatch.setattr(\"hermes_constants.get_hermes_home\", lambda: hermes_home)\n-    monkeypatch.setattr(\"hermes_cli.config.get_hermes_home\", lambda: hermes_home)\n-    return cfg_path\n \n \n async def _drive_picker(runner, event):","@@ -201,3 +223,103 @@ async def test_picker_tap_session_flag_does_not_persist(tmp_path, monkeypatch):\n     written = yaml.safe_load(cfg_path.read_text(encoding=\"utf-8\"))\n     assert written[\"model\"][\"default\"] == \"old-model\"\n     assert written[\"model\"][\"provider\"] == \"openai-codex\"\n+\n+\n[redacted:email]\n+async def test_multiplex_picker_keeps_profile_adapter_and_callback_scope(\n+    tmp_path, monkeypatch\n+):\n+    \"\"\"A named profile must present and execute its picker under one identity.\"\"\"\n+    from agent.secret_scope import get_secret, set_multiplex_active\n+\n+    default_adapter = _FakePickerAdapter()\n+    named_adapter = _FakePickerAdapter()\n+    named_home = tmp_path / \"profiles\" / \"named\"\n+    named_home.mkdir(parents=True)\n+    (named_home / \".env\").write_text(\"PROFILE_MODEL_KEY=named-secret\\n\", encoding=\"utf-8\")\n+    runner = _make_named_runner(monkeypatch, default_adapter, named_adapter, named_home)\n+    _setup_isolated_home(\n+        tmp_path,\n+        monkeypatch,\n+        {\"default\": \"old-model\", \"provider\": \"openai-codex\"},\n+    )\n+    resolved = []\n+\n+    def _profile_switch(**kwargs):\n+        resolved.append(get_secret(\"PROFILE_MODEL_KEY\"))\n+        return _fake_switch_result()\n+\n+    monkeypatch.setattr(\"hermes_cli.model_switch.switch_model\", _profile_switch)\n+    event = _named_event(\"--session\")\n+\n+    set_multiplex_active(True)\n+    try:\n+        sent = await runner._handle_model_command(event)\n+\n+        assert sent is None\n+        assert default_adapter.captured_callback is None\n+        assert named_adapter.captured_callback is not None\n+        assert resolved == []\n+\n+        confirmation = await named_adapter.captured_callback(\n+            \"named-chat\", \"gpt-5.5\", \"openrouter\"\n+        )\n+    finally:\n+        set_multiplex_active(False)\n+\n+    assert \"gpt-5.5\" in confirmation\n+    assert resolved == [\"named-secret\"]\n+\n+\n[redacted:email]\n+async def test_multiplex_picker_global_persists_only_named_profile(\n+    tmp_path, monkeypatch\n+):\n+    \"\"\"A named picker must not seed its global write from the default profile.\"\"\"\n+    import gateway.run as gateway_run\n+    from agent.secret_scope import set_multiplex_active\n+\n+    default_home = tmp_path / \"default\"\n+    named_home = tmp_path / \"profiles\" / \"named\"\n+    default_home.mkdir(parents=True)\n+    named_home.mkdir(parents=True)\n+    default_cfg = {\n+        \"marker\": \"default\",\n+        \"model\": {\"default\": \"default-old\", \"provider\": \"openai-codex\"},\n+    }\n+    named_cfg = {\n+        \"marker\": \"named\",\n+        \"model\": {\"default\": \"named-old\", \"provider\": \"openai-codex\"},\n+    }\n+    (default_home / \"config.yaml\").write_text(\n+        yaml.safe_dump(default_cfg, sort_keys=False), encoding=\"utf-8\"\n+    )\n+    (named_home / \"config.yaml\").write_text(\n+        yaml.safe_dump(named_cfg, sort_keys=False), encoding=\"utf-8\"\n+    )\n+\n+    default_adapter = _FakePickerAdapter()\n+    named_adapter = _FakePickerAdapter()\n+    runner = _make_named_runner(monkeypatch, default_adapter, named_adapter, named_home)\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", default_home)\n+    _stub_picker_dependencies(monkeypatch)\n+    event = _named_event(\"--global\")\n+\n+    set_multiplex_active(True)\n+    try:\n+        with gateway_run._profile_runtime_scope(named_home):\n+            sent = await runner._handle_model_command(event)\n+        assert sent is None\n+        assert named_adapter.captured_callback is not None\n+        confirmation = await named_adapter.captured_callback(\n+            \"named-chat\", \"gpt-5.5\", \"openrouter\"\n+        )\n+    finally:\n+        set_multiplex_active(False)\n+\n+    assert \"gpt-5.5\" in confirmation\n+    assert yaml.safe_load((default_home / \"config.yaml\").read_text()) == default_cfg\n+    written = yaml.safe_load((named_home / \"config.yaml\").read_text())\n+    assert written[\"marker\"] == \"named\"\n+    assert written[\"model\"][\"default\"] == \"gpt-5.5\"\n+    assert written[\"model\"][\"provider\"] == \"openrouter\""]},{"file":"tests/gateway/test_multiplex_adapter_registry.py","hunks":["@@ -1,4 +1,6 @@\n \"\"\"Phase 3: secondary-profile adapter registry + same-token conflict detection.\"\"\"\n+import logging\n+\n import pytest\n \n from gateway.run import GatewayRunner","@@ -142,12 +144,12 @@ class _Evt:\n         assert seen[\"profile\"] == \"writer\"\n \n \n-class TestPortBindingHardError:\n-    \"\"\"A secondary profile enabling a port-binding platform aborts startup.\"\"\"\n+class TestSecondaryProfileConfigHandling:\n+    \"\"\"Secondary config errors degrade only when the profile is safe to skip.\"\"\"\n \n     @pytest.mark.asyncio\n-    async def test_secondary_webhook_raises(self, monkeypatch):\n-        from gateway.run import MultiplexConfigError\n+    async def test_secondary_webhook_uses_degradable_error(self, monkeypatch):\n+        from gateway.run import SecondaryPortBindingConfigError\n         from gateway.config import GatewayConfig, Platform, PlatformConfig\n \n         runner = GatewayRunner.__new__(GatewayRunner)","@@ -163,10 +165,148 @@ async def test_secondary_webhook_raises(self, monkeypatch):\n             \"gateway.config.load_gateway_config\", lambda: reviewer_cfg\n         )\n \n-        with pytest.raises(MultiplexConfigError) as ei:\n+        with pytest.raises(SecondaryPortBindingConfigError) as ei:\n             await runner._start_one_profile_adapters(\"reviewer\", \"/tmp/x\", {})\n         assert \"webhook\" in str(ei.value)\n         assert \"reviewer\" in str(ei.value)\n+        assert \"reviewer\" not in runner._profile_adapters\n+\n+    @pytest.mark.asyncio\n+    async def test_secondary_reports_all_port_binding_platforms(self, monkeypatch):\n+        from gateway.run import SecondaryPortBindingConfigError\n+        from gateway.config import GatewayConfig, Platform, PlatformConfig\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(multiplex_profiles=True)\n+        runner._profile_adapters = {}\n+\n+        reviewer_cfg = GatewayConfig(multiplex_profiles=True)\n+        reviewer_cfg.platforms = {\n+            # connection_mode=webhook: with #52563's conditional check merged,\n+            # default (websocket) Feishu no longer binds a port — only webhook\n+            # mode should be reported here.\n+            Platform.FEISHU: PlatformConfig(\n+                enabled=True, extra={\"connection_mode\": \"webhook\"}\n+            ),\n+            Platform.WEBHOOK: PlatformConfig(enabled=True, extra={\"port\": 8644}),\n+            Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"t\"),\n+        }\n+        monkeypatch.setattr(\n+            \"gateway.config.load_gateway_config\", lambda: reviewer_cfg\n+        )\n+\n+        with pytest.raises(SecondaryPortBindingConfigError) as ei:\n+            await runner._start_one_profile_adapters(\"reviewer\", \"/tmp/x\", {})\n+        message = str(ei.value)\n+        assert \"feishu\" in message\n+        assert \"webhook\" in message\n+        assert \"telegram\" not in message\n+        assert \"reviewer\" not in runner._profile_adapters\n+\n+    @pytest.mark.asyncio\n+    async def test_multiplexer_skips_bad_profile_and_continues(self, monkeypatch, caplog):\n+        from pathlib import Path\n+        from gateway.config import GatewayConfig\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(multiplex_profiles=True)\n+        runner.adapters = {}\n+        runner._profile_adapters = {}\n+\n+        async def fake_start_one(profile_name, profile_home, claimed):\n+            if profile_name == \"bad\":\n+                from gateway.run import SecondaryPortBindingConfigError\n+                raise SecondaryPortBindingConfigError(\"bad enables webhook\")\n+            runner._profile_adapters[profile_name] = {}\n+            return 2\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.profiles.profiles_to_serve\",\n+            lambda multiplex: [\n+                (\"default\", Path(\"/tmp/default\")),\n+                (\"bad\", Path(\"/tmp/bad\")),\n+                (\"good\", Path(\"/tmp/good\")),\n+            ],\n+        )\n+        monkeypatch.setattr(\n+            \"hermes_cli.profiles.get_active_profile_name\",\n+            lambda: \"default\",\n+        )\n+        monkeypatch.setattr(runner, \"_start_one_profile_adapters\", fake_start_one)\n+        monkeypatch.setattr(\n+            \"gateway.status.write_runtime_status\",\n+            lambda **kwargs: None,\n+        )\n+\n+        caplog.set_level(logging.WARNING, logger=\"gateway.run\")\n+        connected = await runner._start_secondary_profile_adapters()\n+\n+        assert connected == 2\n+        assert \"good\" in runner._profile_adapters\n+        assert \"bad\" not in runner._profile_adapters\n+        assert \"Skipping secondary profile 'bad'\" in caplog.text\n+\n+    @pytest.mark.asyncio\n+    async def test_multiplexer_propagates_security_config_error(self, monkeypatch):\n+        from pathlib import Path\n+        from gateway.config import GatewayConfig\n+        from gateway.run import MultiplexConfigError\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(multiplex_profiles=True)\n+        runner.adapters = {}\n+        runner._profile_adapters = {}\n+\n+        async def fake_start_one(profile_name, profile_home, claimed):\n+            raise MultiplexConfigError(\n+                f\"Profile '{profile_name}' enables open policy without allow-all opt-in\"\n+            )\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.profiles.profiles_to_serve\",\n+            lambda multiplex: [\n+                (\"default\", Path(\"/tmp/default\")),\n+                (\"unsafe\", Path(\"/tmp/unsafe\")),\n+            ],\n+        )\n+        monkeypatch.setattr(\n+            \"hermes_cli.profiles.get_active_profile_name\",\n+            lambda: \"default\",\n+        )\n+        monkeypatch.setattr(runner, \"_start_one_profile_adapters\", fake_start_one)\n+\n+        with pytest.raises(MultiplexConfigError, match=\"open policy\"):\n+            await runner._start_secondary_profile_adapters()\n+\n+    @pytest.mark.asyncio\n+    async def test_open_policy_uses_fatal_config_error(self, monkeypatch):\n+        from gateway.config import GatewayConfig, Platform, PlatformConfig\n+        from gateway.run import (\n+            MultiplexConfigError,\n+            SecondaryPortBindingConfigError,\n+        )\n+\n+        monkeypatch.delenv(\"GATEWAY_ALLOW_ALL_USERS\", raising=False)\n+        monkeypatch.delenv(\"WECOM_ALLOW_ALL_USERS\", raising=False)\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(multiplex_profiles=True)\n+        runner._profile_adapters = {}\n+\n+        unsafe_cfg = GatewayConfig(multiplex_profiles=True)\n+        unsafe_cfg.platforms = {\n+            Platform.WECOM: PlatformConfig(\n+                enabled=True,\n+                extra={\"dm_policy\": \"open\"},\n+            ),\n+        }\n+        monkeypatch.setattr(\"gateway.config.load_gateway_config\", lambda: unsafe_cfg)\n+\n+        with pytest.raises(MultiplexConfigError, match=\"open policy\") as exc_info:\n+            await runner._start_one_profile_adapters(\"unsafe\", \"/tmp/unsafe\", {})\n+\n+        assert not isinstance(exc_info.value, SecondaryPortBindingConfigError)\n+        assert \"unsafe\" not in runner._profile_adapters\n \n     @pytest.mark.asyncio\n     async def test_secondary_non_binding_platform_ok(self, monkeypatch):","@@ -227,7 +367,7 @@ def set_authorization_check(self, handler):\n         runner._handle_active_session_busy_message = object()\n         runner._recover_telegram_topic_thread_id = object()\n         runner._busy_text_mode = \"queue\"\n-        runner._make_adapter_auth_check = lambda platform: object()\n+        runner._make_adapter_auth_check = lambda platform, profile_name=None: object()\n \n         reviewer_cfg = GatewayConfig(multiplex_profiles=True)\n         reviewer_cfg.platforms = {","@@ -301,7 +441,7 @@ def set_authorization_check(self, handler):\n         runner._handle_active_session_busy_message = object()\n         runner._recover_telegram_topic_thread_id = object()\n         runner._busy_text_mode = \"queue\"\n-        runner._make_adapter_auth_check = lambda platform: object()\n+        runner._make_adapter_auth_check = lambda platform, profile_name=None: object()\n \n         profile_cfg = GatewayConfig(multiplex_profiles=False)\n         profile_cfg.platforms = {","@@ -383,6 +523,84 @@ async def disconnect(self):\n     def test_port_binding_set_covers_known_listeners(self):\n         from gateway.run import _PORT_BINDING_PLATFORM_VALUES\n         # Every adapter that binds a TCP port must be in the guard set.\n-        for p in (\"webhook\", \"api_server\", \"msgraph_webhook\", \"feishu\",\n-                  \"wecom_callback\", \"bluebubbles\", \"sms\"):\n+        for p in (\n+            \"webhook\",\n+            \"api_server\",\n+            \"msgraph_webhook\",\n+            \"feishu\",\n+            \"wecom_callback\",\n+            \"bluebubbles\",\n+            \"sms\",\n+            \"whatsapp_cloud\",\n+            \"line\",\n+        ):\n             assert p in _PORT_BINDING_PLATFORM_VALUES\n+\n+\n+\n+class TestFeishuPortBindingConditional:\n+    \"\"\"Feishu websocket mode does NOT bind a port; only webhook mode does (#52563).\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_feishu_websocket_mode_not_rejected(self, monkeypatch):\n+        \"\"\"Feishu in websocket mode (the default) should NOT raise MultiplexConfigError.\"\"\"\n+        from gateway.run import MultiplexConfigError\n+        from gateway.config import GatewayConfig, Platform, PlatformConfig\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(multiplex_profiles=True)\n+        runner._profile_adapters = {}\n+\n+        reviewer_cfg = GatewayConfig(multiplex_profiles=True)\n+        reviewer_cfg.platforms = {\n+            Platform.FEISHU: PlatformConfig(\n+                enabled=True,\n+                extra={\"app_id\": \"cli_xxx\", \"app_secret\": \"sec\", \"connection_mode\": \"websocket\"},\n+            ),\n+        }\n+        monkeypatch.setattr(\"gateway.config.load_gateway_config\", lambda: reviewer_cfg)\n+        monkeypatch.setattr(runner, \"_create_adapter\", lambda p, c: None)\n+\n+        connected = await runner._start_one_profile_adapters(\"reviewer\", \"/tmp/x\", {})\n+        assert connected == 0  # no error, just nothing connected\n+\n+    @pytest.mark.asyncio\n+    async def test_feishu_webhook_mode_raises(self, monkeypatch):\n+        \"\"\"Feishu in webhook mode binds a port and should raise MultiplexConfigError.\"\"\"\n+        from gateway.run import MultiplexConfigError\n+        from gateway.config import GatewayConfig, Platform, PlatformConfig\n+\n+        runner = GatewayRunner.__new__(GatewayRunner)\n+        runner.config = GatewayConfig(multiplex_profiles=True)\n+        runner._profile_adapters = {}\n+\n+        reviewer_cfg = GatewayConfig(multiplex_profiles=True)\n+        reviewer_cfg.platforms = {\n+            Platform.FEISHU: PlatformConfig(\n+                enabled=True,\n+                extra={\"app_id\": \"cli_xxx\", \"app_secret\": \"sec\", \"connection_mode\": \"webhook\"},\n+            ),\n+        }\n+        monkeypatch.setattr(\"gateway.config.load_gateway_config\", lambda: reviewer_cfg)\n+\n+        with pytest.raises(MultiplexConfigError) as ei:\n+            await runner._start_one_profile_adapters(\"reviewer\", \"/tmp/x\", {})\n+        assert \"feishu\" in str(ei.value)\n+\n+    def test_platform_binds_port_helper(self):\n+        \"\"\"Unit test for _platform_binds_port helper.\"\"\"\n+        from gateway.run import _platform_binds_port\n+\n+        # Non-port-binding platform\n+        assert _platform_binds_port(\"telegram\", {}) is False\n+\n+        # Unconditional port-binding platform\n+        assert _platform_binds_port(\"webhook\", {}) is True\n+        assert _platform_binds_port(\"api_server\", {}) is True\n+\n+        # Feishu: websocket = no port binding\n+        assert _platform_binds_port(\"feishu\", {\"connection_mode\": \"websocket\"}) is False\n+        assert _platform_binds_port(\"feishu\", {}) is False  # default is websocket\n+\n+        # Feishu: webhook = port binding\n+        assert _platform_binds_port(\"feishu\", {\"connection_mode\": \"webhook\"}) is True"]},{"file":"tests/gateway/test_multiplex_profile_authz.py","hunks":["@@ -139,6 +139,56 @@ def test_secondary_allowlist_dm_behavior_ignores_unauthorized(monkeypatch):\n     assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == \"ignore\"\n \n \n+def test_adapter_auth_check_stamps_secondary_profile(monkeypatch):\n+    \"\"\"The adapter auth-check callback must stamp its own secondary profile.\n+\n+    Regression for the gap where ``_make_adapter_auth_check`` built a\n+    profile-less ``SessionSource``, so a secondary adapter's external-context\n+    authorization (e.g. Slack/Discord thread-reply lookups) silently\n+    resolved the *active* profile's allowlist scope instead of its own.\n+    \"\"\"\n+    from gateway.run import GatewayRunner\n+\n+    _clear_auth_env(monkeypatch)\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(multiplex_profiles=True)\n+\n+    captured: dict = {}\n+\n+    def fake_is_user_authorized(source):\n+        captured[\"profile\"] = source.profile\n+        return True\n+\n+    runner._is_user_authorized = fake_is_user_authorized\n+\n+    check = runner._make_adapter_auth_check(Platform.WECOM, profile_name=\"coder\")\n+    assert check(\"some-user\", \"dm\", \"dm-chat\") is True\n+    assert captured[\"profile\"] == \"coder\"\n+\n+\n+def test_adapter_auth_check_defaults_to_active_profile(monkeypatch):\n+    \"\"\"Primary-adapter callbacks (no profile_name) still resolve the active profile.\"\"\"\n+    from gateway.run import GatewayRunner\n+\n+    _clear_auth_env(monkeypatch)\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(multiplex_profiles=True)\n+\n+    captured: dict = {}\n+\n+    def fake_is_user_authorized(source):\n+        captured[\"profile\"] = source.profile\n+        return True\n+\n+    runner._is_user_authorized = fake_is_user_authorized\n+\n+    check = runner._make_adapter_auth_check(Platform.WECOM)\n+    assert check(\"some-user\", \"dm\", \"dm-chat\") is True\n+    assert captured[\"profile\"] is None\n+\n+\n def test_secondary_open_policy_fails_startup_guard(monkeypatch):\n     \"\"\"Secondary profiles must pass the same open-policy startup guard.\"\"\"\n     from gateway.run import _own_policy_open_startup_violation"]},{"file":"tests/gateway/test_status_command.py","hunks":["@@ -672,6 +672,92 @@ async def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path\n     assert f\"**Home:** `{profile_home}`\" in result\n \n \n[redacted:email]\n+async def test_profile_command_reports_source_stamped_profile(monkeypatch, tmp_path):\n+    \"\"\"On a multiplexed gateway, /profile reports the profile SERVING the\n+    source (source.profile — URL prefix / per-credential adapter / room map),\n+    not the multiplexer's active profile, which is always the default and\n+    made /profile answer \"default\" in every persona chat.\"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    profile_home = hermes_home / \"profiles\" / \"milo\"\n+    profile_home.mkdir(parents=True)\n+\n+    session_entry = SessionEntry(\n+        session_key=build_session_key(_make_source()),\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    runner = _make_runner(session_entry)\n+    runner.config.multiplex_profiles = True\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    event = _make_event(\"/profile\")\n+    event.source.profile = \"milo\"\n+\n+    result = await runner._handle_profile_command(event)\n+\n+    assert \"**Profile:** `milo`\" in result\n+    assert f\"**Home:** `{profile_home}`\" in result\n+\n+\n[redacted:email]\n+async def test_profile_command_ignores_stamp_when_multiplexing_off(monkeypatch, tmp_path):\n+    \"\"\"Without ``gateway.multiplex_profiles`` a stamped source is ignored:\n+    /profile keeps reporting the active profile and the default home,\n+    mirroring the multiplex gating in ``_run_agent`` and\n+    ``_reset_notice_session_info``.\"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    profile_home = hermes_home / \"profiles\" / \"milo\"\n+    profile_home.mkdir(parents=True)\n+\n+    session_entry = SessionEntry(\n+        session_key=build_session_key(_make_source()),\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    runner = _make_runner(session_entry)\n+    assert runner.config.multiplex_profiles is False\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    event = _make_event(\"/profile\")\n+    event.source.profile = \"milo\"\n+\n+    result = await runner._handle_profile_command(event)\n+\n+    assert \"**Profile:** `default`\" in result\n+    assert f\"**Home:** `{hermes_home}`\" in result\n+\n+\n[redacted:email]\n+async def test_profile_command_unstamped_source_unchanged(monkeypatch, tmp_path):\n+    \"\"\"Single-profile behavior is untouched: an unstamped source reports the\n+    active profile and the default home.\"\"\"\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+\n+    session_entry = SessionEntry(\n+        session_key=build_session_key(_make_source()),\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    runner = _make_runner(session_entry)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+    result = await runner._handle_profile_command(_make_event(\"/profile\"))\n+\n+    assert \"**Profile:** `default`\" in result\n+    assert f\"**Home:** `{hermes_home}`\" in result\n+\n+\n @pytest.mark.asyncio\n async def test_post_delivery_callback_generation_snapshot_happens_after_bind():\n     \"\"\"Regression: the callback_generation snapshot in _process_message_background"]},{"file":"tests/hermes_cli/test_web_server_messaging_profiles.py","hunks":["@@ -228,3 +228,149 @@ def test_scoped_clear_env_removes_from_target_only(\n             encoding=\"utf-8\"\n         )\n         assert \"TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]\" in root_env\n+\n+\n+def _enable_multiplex(default_home):\n+    (default_home / \"config.yaml\").write_text(\n+        yaml.safe_dump({\"gateway\": {\"multiplex_profiles\": True}}),\n+        encoding=\"utf-8\",\n+    )\n+\n+\n+class TestMultiplexPortBindingGuard:\n+    \"\"\"Enabling a port-binding channel on a secondary multiplexed profile\n+    must be rejected BEFORE anything is persisted.\n+\n+    The gateway fail-fasts with ``MultiplexConfigError`` when a secondary\n+    profile enables a port-binding platform under\n+    ``gateway.multiplex_profiles`` — but the dashboard used to persist that\n+    exact config, so the next gateway start died for EVERY profile (#62791).\n+    \"\"\"\n+\n+    @pytest.fixture(autouse=True)\n+    def _no_multiplex_env_override(self, monkeypatch):\n+        # The operator env override must not leak into these tests: the\n+        # multiplex flag under test comes from the default profile's config.\n+        monkeypatch.delenv(\"GATEWAY_MULTIPLEX_PROFILES\", raising=False)\n+\n+    def test_rejects_every_port_binding_platform_on_secondary(\n+        self, client, isolated_profiles\n+    ):\n+        from gateway.config import PORT_BINDING_PLATFORM_VALUES\n+\n+        _enable_multiplex(isolated_profiles[\"default\"])\n+        assert PORT_BINDING_PLATFORM_VALUES  # guard set must not be empty\n+        for platform_id in sorted(PORT_BINDING_PLATFORM_VALUES):\n+            resp = client.put(\n+                f\"/api/messaging/platforms/{platform_id}\",\n+                params={\"profile\": \"worker_alpha\"},\n+                json={\"enabled\": True},\n+            )\n+            assert resp.status_code == 409, platform_id\n+            assert \"default profile\" in resp.json()[\"detail\"]\n+\n+    def test_body_profile_target_is_also_guarded(self, client, isolated_profiles):\n+        _enable_multiplex(isolated_profiles[\"default\"])\n+        resp = client.put(\n+            \"/api/messaging/platforms/api_server\",\n+            json={\"enabled\": True, \"profile\": \"worker_alpha\"},\n+        )\n+        assert resp.status_code == 409\n+\n+    def test_rejected_request_leaves_env_and_config_untouched(\n+        self, client, isolated_profiles\n+    ):\n+        _enable_multiplex(isolated_profiles[\"default\"])\n+        worker_home = isolated_profiles[\"worker_alpha\"]\n+        env_before = (worker_home / \".env\").read_text(encoding=\"utf-8\")\n+        cfg_before = (worker_home / \"config.yaml\").read_text(encoding=\"utf-8\")\n+\n+        catalog = client.get(\n+            \"/api/messaging/platforms\", params={\"profile\": \"worker_alpha\"}\n+        ).json()\n+        api_server = next(p for p in catalog[\"platforms\"] if p[\"id\"] == \"api_server\")\n+        env = {f[\"key\"]: \"rejected-value\" for f in api_server[\"env_vars\"][:1]}\n+\n+        resp = client.put(\n+            \"/api/messaging/platforms/api_server\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\"enabled\": True, \"env\": env},\n+        )\n+\n+        assert resp.status_code == 409\n+        assert (worker_home / \".env\").read_text(encoding=\"utf-8\") == env_before\n+        assert (worker_home / \"config.yaml\").read_text(encoding=\"utf-8\") == cfg_before\n+\n+    def test_default_profile_still_allowed_with_multiplex_on(\n+        self, client, isolated_profiles\n+    ):\n+        _enable_multiplex(isolated_profiles[\"default\"])\n+        resp = client.put(\n+            \"/api/messaging/platforms/api_server\",\n+            params={\"profile\": \"default\"},\n+            json={\"enabled\": True},\n+        )\n+        assert resp.status_code == 200\n+        cfg = yaml.safe_load(\n+            (isolated_profiles[\"default\"] / \"config.yaml\").read_text()\n+        )\n+        assert cfg[\"platforms\"][\"api_server\"][\"enabled\"] is True\n+\n+    def test_secondary_allowed_when_multiplex_off(self, client, isolated_profiles):\n+        # Fixture default config is {} — multiplexing disabled.\n+        resp = client.put(\n+            \"/api/messaging/platforms/api_server\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\"enabled\": True},\n+        )\n+        assert resp.status_code == 200\n+        cfg = yaml.safe_load(\n+            (isolated_profiles[\"worker_alpha\"] / \"config.yaml\").read_text()\n+        )\n+        assert cfg[\"platforms\"][\"api_server\"][\"enabled\"] is True\n+\n+    def test_secondary_can_disable_and_clear_invalid_config(\n+        self, client, isolated_profiles\n+    ):\n+        _enable_multiplex(isolated_profiles[\"default\"])\n+        worker_home = isolated_profiles[\"worker_alpha\"]\n+        (worker_home / \"config.yaml\").write_text(\n+            yaml.safe_dump({\"platforms\": {\"api_server\": {\"enabled\": True}}}),\n+            encoding=\"utf-8\",\n+        )\n+\n+        resp = client.put(\n+            \"/api/messaging/platforms/api_server\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\"enabled\": False},\n+        )\n+        assert resp.status_code == 200\n+        cfg = yaml.safe_load((worker_home / \"config.yaml\").read_text())\n+        assert cfg[\"platforms\"][\"api_server\"][\"enabled\"] is False\n+\n+        catalog = client.get(\n+            \"/api/messaging/platforms\", params={\"profile\": \"worker_alpha\"}\n+        ).json()\n+        api_server = next(p for p in catalog[\"platforms\"] if p[\"id\"] == \"api_server\")\n+        if api_server[\"env_vars\"]:\n+            resp = client.put(\n+                \"/api/messaging/platforms/api_server\",\n+                params={\"profile\": \"worker_alpha\"},\n+                json={\"clear_env\": [api_server[\"env_vars\"][0][\"key\"]]},\n+            )\n+            assert resp.status_code == 200\n+\n+    def test_non_port_binding_platform_unaffected_on_secondary(\n+        self, client, isolated_profiles\n+    ):\n+        _enable_multiplex(isolated_profiles[\"default\"])\n+        resp = client.put(\n+            \"/api/messaging/platforms/telegram\",\n+            params={\"profile\": \"worker_alpha\"},\n+            json={\"enabled\": True, \"env\": {\"TELEGRAM_BOT_TOKEN\": _VALID_WORKER_BOT_TOKEN}},\n+        )\n+        assert resp.status_code == 200\n+        cfg = yaml.safe_load(\n+            (isolated_profiles[\"worker_alpha\"] / \"config.yaml\").read_text()\n+        )\n+        assert cfg[\"platforms\"][\"telegram\"][\"enabled\"] is True"]},{"file":"website/docs/user-guide/multi-profile-gateways.md","hunks":["@@ -142,18 +142,26 @@ POST http://host:8644/p/coder/webhooks/<route>\n An unknown or unconfigured profile in the prefix returns `404`. Because the one\n shared listener already serves every profile this way, a **secondary profile\n must not enable a port-binding platform itself** — doing so is a config error\n-and the gateway refuses to start, naming the profile and platform:\n+that skips the entire secondary profile while the default and other healthy\n+profiles continue. The warning names the skipped profile and every conflicting\n+platform:\n \n ```\n-Profile 'coder' enables the port-binding platform 'webhook', but\n-gateway.multiplex_profiles is on. ... Remove platforms.webhook from profile\n-'coder's config.yaml (configure it only on the default profile).\n+Skipping secondary profile 'coder' due to port-binding config error: Profile\n+'coder' enables port-binding platform(s) webhook, but gateway.multiplex_profiles\n+is on. ... Remove these platform entries from profile 'coder's config.yaml or\n+configure them only on the default profile.\n ```\n \n Port-binding platforms covered by this rule: `webhook`, `api_server`,\n-`msgraph_webhook`, `feishu`, `wecom_callback`, `bluebubbles`, `sms`. Configure\n-any of these **only on the default profile**; every profile is reachable through\n-its `/p/<profile>/` prefix.\n+`msgraph_webhook`, `feishu`, `wecom_callback`, `bluebubbles`, `sms`,\n+`whatsapp_cloud`, `line`. Configure any of these **only on the default profile**;\n+every profile is reachable through its `/p/<profile>/` prefix.\n+\n+Only this shared-listener conflict degrades to a skipped profile. Security\n+configuration errors remain fatal: for example, an `open` own-policy platform\n+without `GATEWAY_ALLOW_ALL_USERS` or its platform-specific allow-all opt-in\n+still aborts gateway startup rather than silently dropping the unsafe profile.\n \n #### 3. Per-credential platforms still need their own token per profile\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}