# NousResearch/hermes-agent · Feishu wrongly flagged as port-binding in multiplex gateway

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #65700. Verify it addresses your case.
- **Recommended:** read PR #65700 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-65700`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.36`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #52563: Feishu misclassified as port-binding platform, breaking multiplexed Feishu profiles
- #57896: Multiplexing gateway: feishu websocket mode should not be treated as port-binding

## Prior & competing PRs

- **#61437: fix(gateway): remove feishu from port-binding platform set (fixes #52563)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#52563`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#65700: fix(gateway): multiplex wave 2 — port-binding degradation, dashboard guard, scoped picker//profile/api_server (7-PR cluster salvage)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/config.py`

    ```diff
    @@ -313,6 +313,50 @@ def _scan_bundled_plugin_platforms(cls) -> set:
     _BUILTIN_PLATFORM_VALUES = frozenset(m.value for m in Platform.__members__.values())
     
     
    +# Platforms that bind a host TCP port (HTTP/webhook listeners). In a profile
    +# multiplexer the default profile owns the single shared listener and serves
    +# every profile through the /p/<profile>/ URL prefix, so a SECONDARY profile
    +# enabling one of these is always a misconfiguration: it would try to bind a
    +# port already held by the default's listener. Single source of truth for
    +# both the gateway's fail-fast startup validation (gateway/run.py) and the
    +# dashboard's pre-write mutation validation (hermes_cli/web_server.py) so
    +# the two policies cannot drift. Stored as platform .value strings.
    +PORT_BINDING_PLATFORM_VALUES = frozenset({
    +    "webhook",
    +    "api_server",
    +    "msgraph_webhook",
    +    "feishu",
    +    "wecom_callback",
    +    "bluebubbles",
    +    "sms",
    +    "whatsapp_cloud",
    +    "line",
    +})
    +
    +# Platforms whose port-binding status depends on connection mode. Feishu in
    +# websocket mode (its default) uses an outbound long connection — no listener.
    +# Only webhook/callback mode binds a port. Maps platform value → the mode
    +# value that actually binds (#52563).
    +PORT_BINDING_CONDITIONAL_MODES: dict[str, str] = {
    +    "feishu": "webhook",
    +}
    +
    +
    +def platform_binds_port(platform_value: str, extra: Optional[dict] = None) -> bool:
    +    """Return True when *platform_value* actually binds a port for *extra* config.
    +
    +    Mode-conditional platforms (Feishu) only bind in their listener mode;
    +    everything else in ``PORT_BINDING_PLATFORM_VALUES`` always binds.
    +    """
    +    if platform_value not in PORT_BINDING_PLATFORM_VALUES:
    +        return False
    +    expected_mode = PORT_BINDING_CONDITIONAL_MODES.get(platform_value)
    +    if expected_mode is not None:
    +        actual = str((extra or {}).get("connection_mode", "websocket")).strip().lower()
    +        return actual == expected_mode
    +    return True
    +
    +
     @dataclass
     class HomeChannel:
         """
    ```
  - `gateway/platforms/api_server.py`

    ```diff
    @@ -1284,8 +1284,27 @@ def _resolve_request_profile(self, request: "web.Request"):
     
         @staticmethod
         def _profile_scope(profile: Optional[str]):
    -        """Enter the multiplex profile runtime scope, or a no-op when unset."""
    +        """Enter the multiplex profile runtime scope, or a no-op when unset.
    +
    +        When no ``/p/<profile>/`` prefix was given AND multiplexing is active,
    +        enter the DEFAULT profile's scope instead of a no-op: api_server is a
    +        port-binding platform that lives on the default profile, and with
    +        multiplex fail-closed ``get_secret`` active, an unscoped agent run
    +        raises ``UnscopedSecretError`` on its first credential read (#61276).
    +        Single-profile gateways keep the no-op — ``get_secret`` falls through
    +        to ``os.environ`` there, unchanged.
    +        """
             if not profile:
    +            try:
    +                from agent.secret_scope import is_multiplex_active
    +
    +                if is_multiplex_active():
    +                    from gateway.run import _profile_runtime_scope
    +                    from hermes_constants import get_hermes_home
    +
    +                    return _profile_runtime_scope(get_hermes_home())
    +            except Exception:
    +                pass
                 return nullcontext()
             from gateway.run import _profile_runtime_scope
             from hermes_cli.profiles import get_profile_dir
    ```
  - `gateway/run.py`

    ```diff
    @@ -1411,32 +1411,29 @@ def _current_max_iterations() -> int:
     # Platforms that bind a host TCP port (HTTP/webhook listeners). In a profile
     # multiplexer the default profile owns the single shared listener and serves
     # every profile through the /p/<profile>/ URL prefix, so a SECONDARY profile
    -# enabling one of these is always a misconfiguration: it would try to bind a
    -# port already held by the default's listener. We hard-error on it rather than
    -# silently dropping the adapter (see _start_one_profile_adapters).
    -# Stored as platform .value strings since the Platform enum is imported below.
    -_PORT_BINDING_PLATFORM_VALUES = frozenset({
    -    "webhook",
    -    "api_server",
    -    "msgraph_webhook",
    -    "feishu",
    -    "wecom_callback",
    -    "bluebubbles",
    -    "sms",
    -    "whatsapp_cloud",
    -    "line",
    -})
    +# enabling one of these is always a misconfiguration. We skip that secondary
    +# profile (SecondaryPortBindingConfigError) so a single bad profile cannot
    +# take down the whole multiplexer. The set lives in gateway.config so the
    +# dashboard's pre-write validation enforces the same policy.
    +from gateway.config import (
    +    PORT_BINDING_PLATFORM_VALUES as _PORT_BINDING_PLATFORM_VALUES,
    +    platform_binds_port as _platform_binds_port,
    +)
     
     
     class MultiplexConfigError(RuntimeError):
    -    """A profile multiplexer config is invalid (fail-fast at startup).
    +    """A profile multiplexer config is invalid.
     
    -    Distinct from a transient adapter-connect failure: a transient error is
    -    logged and the gateway stays alive to retry, but a config error means the
    -    operator must fix config.yaml, so it aborts startup cleanly.
    +    Distinct from a transient adapter-connect failure: a config error means the
    +    operator must fix config.yaml. Fatal configuration errors propagate to the
    +    startup guard instead of being treated as retryable adapter noise.
         """
     
     
    +class SecondaryPortBindingConfigError(MultiplexConfigError):
    +    """A secondary profile conflicts with the multiplexer's shared listener."""
    +
    +
     @_contextmanager
     def _profile_runtime_scope(profile_home: "Path"):
         """Scope config/skills/memory AND credentials to a profile for one turn.
    ```

    ```diff
    @@ -8754,10 +8751,13 @@ async def _start_secondary_profile_adapters(self) -> int:
                     connected += await self._start_one_profile_adapters(
                         profile_name, profile_home, claimed
                     )
    +            except SecondaryPortBindingConfigError as e:
    +                logger.warning(
    +                    "Skipping secondary profile '%s' due to port-binding config error: %s",
    +                    profile_name,
    +                    e,
    +                )
                 except MultiplexConfigError:
    -                # Config error (e.g. a secondary profile binding a port) is not
    -                # transient — propagate so startup aborts cleanly instead of
    -                # limping along with a half-configured multiplexer.
                     raise
                 except Exception as e:
                     logger.error(
    ```

    ```diff
    @@ -8800,6 +8800,23 @@ async def _start_one_profile_adapters(
                     "'open'."
                 )
     
    +        port_binding_platforms = sorted(
    +            platform.value
    +            for platform, platform_config in profile_cfg.platforms.items()
    +            if platform_config.enabled
    +            and _platform_binds_port(platform.value, platform_config.extra)
    +        )
    +        if port_binding_platforms:
    +            joined = ", ".join(port_binding_platforms)
    +            raise SecondaryPortBindingConfigError(
    +                f"Profile '{profile_name}' enables port-binding platform(s) "
    +                f"{joined}, but gateway.multiplex_profiles is on. The default "
    +                f"profile owns the single shared HTTP listener and serves every "
    +                f"profile through the /p/{profile_name}/ URL prefix. Remove "
    +                f"these platform entries from profile '{profile_name}'s config.yaml "
    +                f"or configure them only on the default profile."
    +            )
    +
             profile_map = self._profile_adapters.setdefault(profile_name, {})
             connected = 0
             for platform, platform_config in profile_cfg.platforms.items():
    ```

    ```diff
    @@ -8813,21 +8830,6 @@ async def _start_one_profile_adapters(
                     and platform is Platform.RELAY
                 ):
                     continue
    -            # A secondary profile must NOT enable a port-binding platform: the
    -            # default profile's listener already serves every profile via the
    -            # /p/<profile>/ 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 "
    -                    f"'{platform.value}', but gateway.multiplex_profiles is on. The "
    -                    f"default profile owns the single shared HTTP listener and "
    -                    f"serves every profile through the /p/{profile_name}/ URL "
    -                    f"prefix — a secondary profile cannot bind its own port. "
    -                    f"Remove platforms.{platform.value} from profile "
    -                    f"'{profile_name}'s config.yaml (configure it only on the "
    -                    f"default profile)."
    -                )
                 try:
                     with _profile_runtime_scope(profile_home):
                         adapter = self._create_adapter(platform, platform_config)
    ```

    ```diff
    @@ -8873,7 +8875,9 @@ async def _start_one_profile_adapters(
                 adapter.set_session_store(self.session_store)
                 adapter.set_busy_session_handler(self._handle_active_session_busy_message)
                 adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id)
    -            adapter.set_authorization_check(self._make_adapter_auth_check(adapter.platform))
    +            adapter.set_authorization_check(
    +                self._make_adapter_auth_check(adapter.platform, profile_name=profile_name)
    +            )
                 adapter._busy_text_mode = self._busy_text_mode
     
                 try:
    ```

    ```diff
    @@ -9090,6 +9094,7 @@ def _create_adapter(
         def _make_adapter_auth_check(
             self,
             platform: Platform,
    +        profile_name: Optional[str] = None,
         ) -> Callable[[str, Optional[str], Optional[str]], bool]:
             """Build a platform-bound auth callback for adapter use.
     
    ```

    ```diff
    @@ -9102,6 +9107,10 @@ def _make_adapter_auth_check(
             The returned callback delegates to :meth:`_is_user_authorized` so the
             full auth chain — platform allowlists, group allowlists, pairing
             store, allow-all flags — stays the single source of truth.
    +
    +        ``profile_name`` binds the callback to the secondary adapter's own
    +        multiplex profile, so its ``SessionSource`` resolves that profile's
    +        secret scope instead of falling back to the active profile.
             """
             def check(
                 user_id: str,
    ```

    ```diff
    @@ -9115,6 +9124,7 @@ def check(
                     chat_id=chat_id or "",
                     chat_type=chat_type or "group",
                     user_id=user_id,
    +                profile=profile_name,
                 )
                 return self._is_user_authorized(source)
             return check
    ```
  - `gateway/slash_commands.py`

    ```diff
    @@ -330,12 +330,44 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
             return EphemeralReply(f"{header}{_tip_line}")
     
         async def _handle_profile_command(self, event: MessageEvent) -> str:
    -        """Handle /profile — show active profile name and home directory."""
    +        """Handle /profile — show the profile serving this source and its home.
    +
    +        On a multiplexed gateway the process-level active profile is always
    +        the multiplexer's own (usually ``default``), so reporting it would
    +        answer "default" in every chat regardless of which profile actually
    +        serves the room/channel (``source.profile`` — stamped by the
    +        ``/p/<profile>/`` URL prefix, a per-credential adapter, or a room→
    +        profile map). When ``multiplex_profiles`` is on, report the stamped
    +        profile and, like the scoped /reset banner (#59003), resolve the
    +        displayed home under that profile's runtime scope. When multiplexing
    +        is off (the default) the stamp is ignored — mirroring the gating in
    +        ``_run_agent`` and ``_reset_notice_session_info`` — and the command
    +        reports the active profile and default home, byte-identical to before.
    +        """
             from hermes_constants import display_hermes_home
             from hermes_cli.profiles import get_active_profile_name
     
    -        display = display_hermes_home()
    -        profile_name = get_active_profile_name()
    +        multiplexed = getattr(
    +            getattr(self, "config", None), "multiplex_profiles", False
    +        )
    +        source = getattr(event, "source", None)
    +
    +        profile_name = ""
    +        if multiplexed:
    +            profile_name = (getattr(source, "profile", "") or "").strip()
    +        profile_name = profile_name or get_active_profile_name()
    +
    +        if multiplexed:
    +            try:
    +                from gateway.run import _profile_runtime_scope
    +
    +                profile_home = self._resolve_profile_home_for_source(source)
    +                with _profile_runtime_scope(profile_home):
    +                    display = display_hermes_home()
    +            except Exception:
    +                display = display_hermes_home()
    +        else:
    +            display = display_hermes_home()
     
             lines = [
                 t("gateway.profile.header", profile=profile_name),
    ```

    ```diff
    @@ -1438,6 +1470,12 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
             from hermes_cli.providers import get_label
     
             raw_args = event.get_command_args().strip()
    +        source = event.source
    +        _command_profile_home = None
    +        if getattr(getattr(self, "config", None), "multiplex_profiles", False):
    +            _command_profile_home = getattr(
    +                self, "_resolve_profile_home_for_source"
    +            )(source)
     
             # Parse --provider, --global, --session, and --refresh flags
             (
    ```

    ```diff
    @@ -1464,7 +1502,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
             current_api_key = ""
             user_provs = None
             custom_provs = None
    -        config_path = _hermes_home / "config.yaml"
    +        config_path = (_command_profile_home or _hermes_home) / "config.yaml"
             try:
                 cfg = _load_gateway_config()
                 if cfg:
    ```

    ```diff
    @@ -1482,9 +1520,8 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
             except Exception:
                 pass
     
    -        # Check for session override
    -        source = event.source
    -        # Normalize the source the same way a normal message turn does
    +        # Check for session override. Normalize the source the same way a normal
    +        # message turn does
             # (Telegram DM topic recovery) before deriving the override key, so
             # the override is stored under the key the next message turn reads
             # (#30479).
    ```

    ```diff
    @@ -1500,7 +1537,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
             # No args: show interactive picker (Telegram/Discord) or text list
             if not model_input and not explicit_provider:
                 # Try interactive picker if the platform supports it
    -            adapter = self.adapters.get(source.platform)
    +            adapter = getattr(self, "_adapter_for_source")(source)
                 has_picker = (
                     adapter is not None
                     and getattr(type(adapter), "send_model_picker", None) is not None
    ```

    ```diff
    @@ -1533,8 +1570,9 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
                         _cur_provider = current_provider
                         _cur_base_url = current_base_url
                         _cur_api_key = [redacted:secret-assignment]
    +                    _picker_profile_home = _command_profile_home
     
    -                    async def _on_model_selected(
    +                    async def _on_model_selected_scoped(
                             _chat_id: str, model_id: str, provider_slug: str
                         ) -> str:
                             """Perform the model switch and return confirmation text."""
    ```

    ```diff
    @@ -1733,6 +1771,20 @@ async def _on_model_selected(
                                 lines.append(t("gateway.model.session_only_hint"))
                             return "\n".join(lines)
     
    +                    async def _on_model_selected(
    +                        _chat_id: str, model_id: str, provider_slug: str
    +                    ) -> str:
    +                        if _picker_profile_home is None:
    +                            return await _on_model_selected_scoped(
    +                                _chat_id, model_id, provider_slug
    +                            )
    +                        from gateway.run import _profile_runtime_scope
    +
    +                        with _profile_runtime_scope(_picker_profile_home):
    +                            return await _on_model_selected_scoped(
    +                                _chat_id, model_id, provider_slug
    +                            )
    +
                         metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event))
                         result = await adapter.send_model_picker(
                             chat_id=source.chat_id,
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -2467,7 +2467,7 @@ async def git_branch_switch_route(body: GitBranchSwitchBody):
     
     # Host TCP ports each port-binding gateway platform listens on, as
     # ``platform-name -> (config port key, adapter default)``.  Mirrors
    -# ``_PORT_BINDING_PLATFORM_VALUES`` in gateway/run.py and each adapter's
    +# ``PORT_BINDING_PLATFORM_VALUES`` in gateway/config.py and each adapter's
     # DEFAULT_PORT / DEFAULT_WEBHOOK_PORT constant.  Used only for the dashboard's
     # gateway-topology readout — best-effort display data, not a bind source.
     _PORT_BINDING_PLATFORM_PORTS: Dict[str, Tuple[str, int]] = {
    ```

    ```diff
    @@ -8088,6 +8088,57 @@ async def get_messaging_platforms(profile: Optional[str] = None):
             }
     
     
    +def _multiplex_port_binding_conflict(
    +    platform_id: str, requested_profile: Optional[str]
    +) -> Optional[str]:
    +    """Reason enabling ``platform_id`` on the target profile would break a
    +    multiplexed gateway, or ``None`` when the change is allowed.
    +
    +    Mirrors the gateway's startup rule (``_start_one_profile_adapters`` in
    +    gateway/run.py): with ``gateway.multiplex_profiles`` on, the default
    +    profile owns the single shared HTTP listener and serves every profile via
    +    the ``/p/<profile>/`` prefix, so a SECONDARY profile must never enable a
    +    port-binding platform. Without this pre-write check the dashboard happily
    +    persisted the invalid config and the shared gateway died with
    +    ``MultiplexConfigError`` on its next start — for ALL profiles. Only
    +    *enabling* is blocked; disabling/clearing stays allowed so users can
    +    repair an already-invalid profile.
    +    """
    +    from gateway.config import PORT_BINDING_PLATFORM_VALUES, load_gateway_config
    +
    +    if platform_id not in PORT_BINDING_PLATFORM_VALUES:
    +        return None
    +
    +    requested = (requested_profile or "").strip()
    +    if not requested or requested.lower() == "current":
    +        from hermes_cli.profiles import get_active_profile_name
    +
    +        # The dashboard's own profile. "custom" (an unrecognized HERMES_HOME)
    +        # is outside the profiles tree, so a multiplexed gateway never serves
    +        # it — nothing to guard.
    +        target = get_active_profile_name()
    +    else:
    +        _resolve_profile_dir(requested)  # same 400/404 as _profile_scope
    +        target = requested
    +    if target in ("default", "custom"):
    +        return None
    +
    +    # The multiplex flag that matters is the one the shared gateway reads at
    +    # startup: the DEFAULT profile's gateway config (plus the process-wide
    +    # GATEWAY_MULTIPLEX_PROFILES override, which load_gateway_config applies).
    +    with _config_profile_scope("default"):
    +        if not load_gateway_config().multiplex_profiles:
    +            return None
    +
    +    return (
    +        f"Cannot enable '{platform_id}' on profile '{target}': it binds its "
    +        "own listener port, and gateway.multiplex_profiles is on, so the "
    +        "default profile owns the single shared HTTP listener for every "
    +        "profile. Configure this channel on the default profile instead "
    +        "(disabling or clearing it here is still allowed)."
    +    )
    +
    +
     @app.put("/api/messaging/platforms/{platform_id}")
     async def update_messaging_platform(
         platform_id: str, body: MessagingPlatformUpdate, profile: Optional[str] = None
    ```

    ```diff
    @@ -8098,6 +8149,20 @@ async def update_messaging_platform(
                 status_code=404, detail=f"Unknown messaging platform: {platform_id}"
             )
     
    +    target_profile = body.profile or profile
    +    if body.enabled:
    +        conflict = _multiplex_port_binding_conflict(platform_id, target_profile)
    +        if conflict:
    +            # Reject BEFORE any .env/config.yaml write so the profile stays
    +            # loadable by the multiplexed gateway.
    +            _log.info(
    +                "Rejected messaging platform update: platform=%s profile=%s "
    +                "(multiplex port-binding conflict)",
    +                platform_id,
    +                target_profile or "current",
    +            )
    +            raise HTTPException(status_code=409, detail=conflict)
    +
         allowed_env = set(entry["env_vars"])
         try:
             with _profile_scope(body.profile or profile):
    ```

    ```diff
    @@ -8123,6 +8188,16 @@ async def update_messaging_platform(
                 if body.enabled is not None:
                     _write_platform_enabled(platform_id, body.enabled)
     
    +        # Audit trail for channel config mutations: names only, never values.
    +        _log.info(
    +            "Messaging platform updated: platform=%s profile=%s enabled=%s "
    +            "env_keys=%s cleared_keys=%s",
    +            platform_id,
    +            target_profile or "current",
    +            body.enabled,
    +            sorted(body.env),
    +            sorted(body.clear_env),
    +        )
             return {"ok": True, "platform": platform_id}
         except HTTPException:
             raise
    ```
  - `scripts/release.py`

    ```diff
    @@ -46,6 +46,7 @@
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
         "[redacted:email]": "karfly",  # PR #64012 salvage (gateway: surface extended reasoning efforts)
    +    "[redacted:email]": "rlaehddus302",  # PR #61985 salvage (gateway: secondary-adapter auth callback profile)
         "[redacted:email]": "aguung",  # PR #64461 salvage (gateway: multiplex secret_scope for authz/Slack/webhooks)
         "[redacted:email]": "jtstothard",  # PR #63256 salvage (gateway: multiplex secondary adapter config validation)
         "[redacted:email]": "SAMBAS123",  # PR #64986 salvage (gateway: multiplex primary bot token scope)
    ```
  - `tests/gateway/test_api_server_multiplex_secret_scope.py`

    ```diff
    @@ -0,0 +1,81 @@
    +"""Regression for #61276: api_server agent entry under multiplex isolation.
    +
    +When gateway.multiplex_profiles is on, get_secret fails closed without a
    +profile secret scope. Requests with a ``/p/<profile>/`` prefix are scoped by
    +``_profile_scope(profile)``, but plain requests on the default listener used
    +to get ``nullcontext()`` — so agent runs crashed with UnscopedSecretError on
    +their first credential read (e.g. OPENROUTER_BASE_URL). ``_profile_scope``
    +now enters the DEFAULT profile's runtime scope when multiplex is active and
    +no profile was requested.
    +
    +Adapted from PR #61283 by @giggling-ginger (originally targeting a
    +pre-``_profile_scope`` helper); no live gateway or network.
    +"""
    +
    +from __future__ import annotations
    +
    +import pytest
    +
    +from agent import secret_scope as ss
    +from gateway.config import PlatformConfig
    +from gateway.platforms.api_server import APIServerAdapter
    +
    +
    [redacted:email](autouse=True)
    +def _reset_multiplex():
    +    ss.set_multiplex_active(False)
    +    yield
    +    ss.set_multiplex_active(False)
    +
    +
    [redacted:email]
    +def adapter():
    +    return APIServerAdapter(PlatformConfig(enabled=True))
    +
    +
    +class TestProfileScopeDefaultFallback:
    +    def test_noop_when_multiplex_off(self, adapter, monkeypatch):
    +        monkeypatch.setenv("OPENROUTER_BASE_URL", "https://from-environ.example/v1")
    +        with adapter._profile_scope(None):
    +            # Legacy single-profile path: unscoped get_secret reads os.environ.
    +            assert ss.get_secret("OPENROUTER_BASE_URL") == "https://from-environ.example/v1"
    +        assert ss.current_secret_scope() is None
    +
    +    def test_default_scope_installed_under_multiplex(self, adapter, tmp_path, monkeypatch):
    +        """No /p/ prefix + multiplex active → default profile scope, not nullcontext."""
    +        (tmp_path / ".env").write_text(
    +            "OPENROUTER_BASE_URL=https://openrouter.ai/api/v1\n",
    +            encoding="utf-8",
    +        )
    +        monkeypatch.setattr(
    +            "hermes_constants.get_hermes_home",
    +            lambda: tmp_path,
    +        )
    +        monkeypatch.setenv("OPENROUTER_BASE_URL", "https://leak.example/v1")
    +        ss.set_multiplex_active(True)
    +
    +        with adapter._profile_scope(None):
    +            assert ss.current_secret_scope() is not None
    +            # Profile .env wins; process env must not leak through.
    +            assert ss.get_secret("OPENROUTER_BASE_URL") == "https://openrouter.ai/api/v1"
    +
    +        # Scope torn down; fail-closed behavior restored outside.
    +        assert ss.current_secret_scope() is None
    +        with pytest.raises(ss.UnscopedSecretError):
    +            ss.get_secret("OPENROUTER_BASE_URL")
    +
    +    def test_named_profile_scope_still_wins(self, adapter, tmp_path, monkeypatch):
    +        """A /p/<profile>/ request keeps resolving that profile's scope."""
    +        profile_home = tmp_path / "profiles" / "worker"
    +        profile_home.mkdir(parents=True)
    +        (profile_home / ".env").write_text(
    +            "OPENROUTER_BASE_URL=https://worker.example/v1\n", encoding="utf-8"
    +        )
    +        monkeypatch.setattr(
    +            "hermes_cli.profiles.get_profile_dir", lambda name: profile_home
    +        )
    +        ss.set_multiplex_active(True)
    +
    +        with adapter._profile_scope("worker"):
    +            assert ss.get_secret("OPENROUTER_BASE_URL") == "https://worker.example/v1"
    +        assert ss.current_secret_scope() is None
    ```
  - `tests/gateway/test_model_picker_persist.py`

    ```diff
    @@ -80,6 +80,22 @@ def _fake_switch_result():
         )
     
     
    +def _stub_picker_dependencies(monkeypatch):
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr(
    +        "hermes_cli.model_switch.list_picker_providers",
    +        lambda **kw: [{"slug": "openrouter", "name": "OpenRouter", "models": ["gpt-5.5"]}],
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.model_switch.switch_model",
    +        lambda **kw: _fake_switch_result(),
    +    )
    +    monkeypatch.setattr(
    +        "hermes_cli.model_switch.resolve_display_context_length",
    +        lambda *a, **k: 272000,
    +    )
    +
    +
     def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value):
         """Write a config.yaml with the given ``model:`` value and stub heavy bits."""
         import gateway.run as gateway_run
    ```

    ```diff
    @@ -93,35 +109,41 @@ def _setup_isolated_home(tmp_path, monkeypatch, model_yaml_value):
         )
     
         monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
    -    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    -    # The picker-setup path calls list_picker_providers, which otherwise hits
    -    # the network (OpenRouter model catalog). Stub it to a minimal list — these
    -    # tests capture and fire the on_model_selected callback and don't assert on
    -    # picker contents. The handler imports it as a local alias at call time, so
    -    # patching the source-module attribute takes effect.
    +    _stub_picker_dependencies(monkeypatch)
    +    # save_config writes to ``get_hermes_home() / config.yaml`` — point it here.
    +    monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
    +    monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
    +    return cfg_path
    +
    +
    +def _make_named_runner(monkeypatch, default_adapter, named_adapter, named_home):
    +    runner = _make_runner(default_adapter)
         monkeypatch.setattr(
    -        "hermes_cli.model_switch.list_picker_providers",
    -        lambda **kw: [{"slug": "openrouter", "name": "OpenRouter", "models": ["gpt-5.5"]}],
    +        runner, "config", types.SimpleNamespace(multiplex_profiles=True), raising=False
         )
    -    # switch_model is imported as a local alias inside the handler
    -    # (`from hermes_cli.model_switch import switch_model as _switch_model`),
    -    # so patching the source-module attribute takes effect at call time.
         monkeypatch.setattr(
    -        "hermes_cli.model_switch.switch_model",
    -        lambda **kw: _fake_switch_result(),
    +        runner,
    +        "_profile_adapters",
    +        {"named": {Platform.TELEGRAM: named_adapter}},
    +        raising=False,
         )
    -    # The confirmation builder resolves context length for display, which
    -    # otherwise makes real outbound HTTP calls (Ollama /api/show + the
    -    # OpenRouter models catalog). Stub it — these tests don't assert on the
    -    # displayed context, and the closure imports it lazily from this module.
         monkeypatch.setattr(
    -        "hermes_cli.model_switch.resolve_display_context_length",
    -        lambda *a, **k: 272000,
    +        runner, "_resolve_profile_home_for_source", lambda source: named_home
    +    )
    +    return runner
    +
    +
    +def _named_event(args):
    +    return MessageEvent(
    +        text=f"/model {args}".rstrip(),
    +        message_type=MessageType.TEXT,
    +        source=SessionSource(
    +            platform=Platform.TELEGRAM,
    +            chat_id="named-chat",
    +            chat_type="dm",
    +            profile="named",
    +        ),
         )
    -    # save_config writes to ``get_hermes_home() / config.yaml`` — point it here.
    -    monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
    -    monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
    -    return cfg_path
     
     
     async def _drive_picker(runner, event):
    ```

    ```diff
    @@ -201,3 +223,103 @@ async def test_picker_tap_session_flag_does_not_persist(tmp_path, monkeypatch):
         written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
         assert written["model"]["default"] == "old-model"
         assert written["model"]["provider"] == "openai-codex"
    +
    +
    [redacted:email]
    +async def test_multiplex_picker_keeps_profile_adapter_and_callback_scope(
    +    tmp_path, monkeypatch
    +):
    +    """A named profile must present and execute its picker under one identity."""
    +    from agent.secret_scope import get_secret, set_multiplex_active
    +
    +    default_adapter = _FakePickerAdapter()
    +    named_adapter = _FakePickerAdapter()
    +    named_home = tmp_path / "profiles" / "named"
    +    named_home.mkdir(parents=True)
    +    (named_home / ".env").write_text("PROFILE_MODEL_KEY=named-secret\n", encoding="utf-8")
    +    runner = _make_named_runner(monkeypatch, default_adapter, named_adapter, named_home)
    +    _setup_isolated_home(
    +        tmp_path,
    +        monkeypatch,
    +        {"default": "old-model", "provider": "openai-codex"},
    +    )
    +    resolved = []
    +
    +    def _profile_switch(**kwargs):
    +        resolved.append(get_secret("PROFILE_MODEL_KEY"))
    +        return _fake_switch_result()
    +
    +    monkeypatch.setattr("hermes_cli.model_switch.switch_model", _profile_switch)
    +    event = _named_event("--session")
    +
    +    set_multiplex_active(True)
    +    try:
    +        sent = await runner._handle_model_command(event)
    +
    +        assert sent is None
    +        assert default_adapter.captured_callback is None
    +        assert named_adapter.captured_callback is not None
    +        assert resolved == []
    +
    +        confirmation = await named_adapter.captured_callback(
    +            "named-chat", "gpt-5.5", "openrouter"
    +        )
    +    finally:
    +        set_multiplex_active(False)
    +
    +    assert "gpt-5.5" in confirmation
    +    assert resolved == ["named-secret"]
    +
    +
    [redacted:email]
    +async def test_multiplex_picker_global_persists_only_named_profile(
    +    tmp_path, monkeypatch
    +):
    +    """A named picker must not seed its global write from the default profile."""
    +    import gateway.run as gateway_run
    +    from agent.secret_scope import set_multiplex_active
    +
    +    default_home = tmp_path / "default"
    +    named_home = tmp_path / "profiles" / "named"
    +    default_home.mkdir(parents=True)
    +    named_home.mkdir(parents=True)
    +    default_cfg = {
    +        "marker": "default",
    +        "model": {"default": "default-old", "provider": "openai-codex"},
    +    }
    +    named_cfg = {
    +        "marker": "named",
    +        "model": {"default": "named-old", "provider": "openai-codex"},
    +    }
    +    (default_home / "config.yaml").write_text(
    +        yaml.safe_dump(default_cfg, sort_keys=False), encoding="utf-8"
    +    )
    +    (named_home / "config.yaml").write_text(
    +        yaml.safe_dump(named_cfg, sort_keys=False), encoding="utf-8"
    +    )
    +
    +    default_adapter = _FakePickerAdapter()
    +    named_adapter = _FakePickerAdapter()
    +    runner = _make_named_runner(monkeypatch, default_adapter, named_adapter, named_home)
    +    monkeypatch.setattr(gateway_run, "_hermes_home", default_home)
    +    _stub_picker_dependencies(monkeypatch)
    +    event = _named_event("--global")
    +
    +    set_multiplex_active(True)
    +    try:
    +        with gateway_run._profile_runtime_scope(named_home):
    +            sent = await runner._handle_model_command(event)
    +        assert sent is None
    +        assert named_adapter.captured_callback is not None
    +        confirmation = await named_adapter.captured_callback(
    +            "named-chat", "gpt-5.5", "openrouter"
    +        )
    +    finally:
    +        set_multiplex_active(False)
    +
    +    assert "gpt-5.5" in confirmation
    +    assert yaml.safe_load((default_home / "config.yaml").read_text()) == default_cfg
    +    written = yaml.safe_load((named_home / "config.yaml").read_text())
    +    assert written["marker"] == "named"
    +    assert written["model"]["default"] == "gpt-5.5"
    +    assert written["model"]["provider"] == "openrouter"
    ```
  - `tests/gateway/test_multiplex_adapter_registry.py`

    ```diff
    @@ -1,4 +1,6 @@
     """Phase 3: secondary-profile adapter registry + same-token conflict detection."""
    +import logging
    +
     import pytest
     
     from gateway.run import GatewayRunner
    ```

    ```diff
    @@ -142,12 +144,12 @@ class _Evt:
             assert seen["profile"] == "writer"
     
     
    -class TestPortBindingHardError:
    -    """A secondary profile enabling a port-binding platform aborts startup."""
    +class TestSecondaryProfileConfigHandling:
    +    """Secondary config errors degrade only when the profile is safe to skip."""
     
         @pytest.mark.asyncio
    -    async def test_secondary_webhook_raises(self, monkeypatch):
    -        from gateway.run import MultiplexConfigError
    +    async def test_secondary_webhook_uses_degradable_error(self, monkeypatch):
    +        from gateway.run import SecondaryPortBindingConfigError
             from gateway.config import GatewayConfig, Platform, PlatformConfig
     
             runner = GatewayRunner.__new__(GatewayRunner)
    ```

    ```diff
    @@ -163,10 +165,148 @@ async def test_secondary_webhook_raises(self, monkeypatch):
                 "gateway.config.load_gateway_config", lambda: reviewer_cfg
             )
     
    -        with pytest.raises(MultiplexConfigError) as ei:
    +        with pytest.raises(SecondaryPortBindingConfigError) as ei:
                 await runner._start_one_profile_adapters("reviewer", "/tmp/x", {})
             assert "webhook" in str(ei.value)
             assert "reviewer" in str(ei.value)
    +        assert "reviewer" not in runner._profile_adapters
    +
    +    @pytest.mark.asyncio
    +    async def test_secondary_reports_all_port_binding_platforms(self, monkeypatch):
    +        from gateway.run import SecondaryPortBindingConfigError
    +        from gateway.config import GatewayConfig, Platform, PlatformConfig
    +
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = GatewayConfig(multiplex_profiles=True)
    +        runner._profile_adapters = {}
    +
    +        reviewer_cfg = GatewayConfig(multiplex_profiles=True)
    +        reviewer_cfg.platforms = {
    +            # connection_mode=webhook: with #52563's conditional check merged,
    +            # default (websocket) Feishu no longer binds a port — only webhook
    +            # mode should be reported here.
    +            Platform.FEISHU: PlatformConfig(
    +                enabled=True, extra={"connection_mode": "webhook"}
    +            ),
    +            Platform.WEBHOOK: PlatformConfig(enabled=True, extra={"port": 8644}),
    +            Platform.TELEGRAM: PlatformConfig(enabled=True, token="t"),
    +        }
    +        monkeypatch.setattr(
    +            "gateway.config.load_gateway_config", lambda: reviewer_cfg
    +        )
    +
    +        with pytest.raises(SecondaryPortBindingConfigError) as ei:
    +            await runner._start_one_profile_adapters("reviewer", "/tmp/x", {})
    +        message = str(ei.value)
    +        assert "feishu" in message
    +        assert "webhook" in message
    +        assert "telegram" not in message
    +        assert "reviewer" not in runner._profile_adapters
    +
    +    @pytest.mark.asyncio
    +    async def test_multiplexer_skips_bad_profile_and_continues(self, monkeypatch, caplog):
    +        from pathlib import Path
    +        from gateway.config import GatewayConfig
    +
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = GatewayConfig(multiplex_profiles=True)
    +        runner.adapters = {}
    +        runner._profile_adapters = {}
    +
    +        async def fake_start_one(profile_name, profile_home, claimed):
    +            if profile_name == "bad":
    +                from gateway.run import SecondaryPortBindingConfigError
    +                raise SecondaryPortBindingConfigError("bad enables webhook")
    +            runner._profile_adapters[profile_name] = {}
    +            return 2
    +
    +        monkeypatch.setattr(
    +            "hermes_cli.profiles.profiles_to_serve",
    +            lambda multiplex: [
    +                ("default", Path("/tmp/default")),
    +                ("bad", Path("/tmp/bad")),
    +                ("good", Path("/tmp/good")),
    +            ],
    +        )
    +        monkeypatch.setattr(
    +            "hermes_cli.profiles.get_active_profile_name",
    +            lambda: "default",
    +        )
    +        monkeypatch.setattr(runner, "_start_one_profile_adapters", fake_start_one)
    +        monkeypatch.setattr(
    +            "gateway.status.write_runtime_status",
    +            lambda **kwargs: None,
    +        )
    +
    +        caplog.set_level(logging.WARNING, logger="gateway.run")
    +        connected = await runner._start_secondary_profile_adapters()
    +
    +        assert connected == 2
    +        assert "good" in runner._profile_adapters
    +        assert "bad" not in runner._profile_adapters
    +        assert "Skipping secondary profile 'bad'" in caplog.text
    +
    +    @pytest.mark.asyncio
    +    async def test_multiplexer_propagates_security_config_error(self, monkeypatch):
    +        from pathlib import Path
    +        from gateway.config import GatewayConfig
    +        from gateway.run import MultiplexConfigError
    +
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = GatewayConfig(multiplex_profiles=True)
    +        runner.adapters = {}
    +        runner._profile_adapters = {}
    +
    +        async def fake_start_one(profile_name, profile_home, claimed):
    +            raise MultiplexConfigError(
    +                f"Profile '{profile_name}' enables open policy without allow-all opt-in"
    +            )
    +
    +        monkeypatch.setattr(
    +            "hermes_cli.profiles.profiles_to_serve",
    +            lambda multiplex: [
    +                ("default", Path("/tmp/default")),
    +                ("unsafe", Path("/tmp/unsafe")),
    +            ],
    +        )
    +        monkeypatch.setattr(
    +            "hermes_cli.profiles.get_active_profile_name",
    +            lambda: "default",
    +        )
    +        monkeypatch.setattr(runner, "_start_one_profile_adapters", fake_start_one)
    +
    +        with pytest.raises(MultiplexConfigError, match="open policy"):
    +            await runner._start_secondary_profile_adapters()
    +
    +    @pytest.mark.asyncio
    +    async def test_open_policy_uses_fatal_config_error(self, monkeypatch):
    +        from gateway.config import GatewayConfig, Platform, PlatformConfig
    +        from gateway.run import (
    +            MultiplexConfigError,
    +            SecondaryPortBindingConfigError,
    +        )
    +
    +        monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
    +        monkeypatch.delenv("WECOM_ALLOW_ALL_USERS", raising=False)
    +
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = GatewayConfig(multiplex_profiles=True)
    +        runner._profile_adapters = {}
    +
    +        unsafe_cfg = GatewayConfig(multiplex_profiles=True)
    +        unsafe_cfg.platforms = {
    +            Platform.WECOM: PlatformConfig(
    +                enabled=True,
    +                extra={"dm_policy": "open"},
    +            ),
    +        }
    +        monkeypatch.setattr("gateway.config.load_gateway_config", lambda: unsafe_cfg)
    +
    +        with pytest.raises(MultiplexConfigError, match="open policy") as exc_info:
    +            await runner._start_one_profile_adapters("unsafe", "/tmp/unsafe", {})
    +
    +        assert not isinstance(exc_info.value, SecondaryPortBindingConfigError)
    +        assert "unsafe" not in runner._profile_adapters
     
         @pytest.mark.asyncio
         async def test_secondary_non_binding_platform_ok(self, monkeypatch):
    ```

    ```diff
    @@ -227,7 +367,7 @@ def set_authorization_check(self, handler):
             runner._handle_active_session_busy_message = object()
             runner._recover_telegram_topic_thread_id = object()
             runner._busy_text_mode = "queue"
    -        runner._make_adapter_auth_check = lambda platform: object()
    +        runner._make_adapter_auth_check = lambda platform, profile_name=None: object()
     
             reviewer_cfg = GatewayConfig(multiplex_profiles=True)
             reviewer_cfg.platforms = {
    ```

    ```diff
    @@ -301,7 +441,7 @@ def set_authorization_check(self, handler):
             runner._handle_active_session_busy_message = object()
             runner._recover_telegram_topic_thread_id = object()
             runner._busy_text_mode = "queue"
    -        runner._make_adapter_auth_check = lambda platform: object()
    +        runner._make_adapter_auth_check = lambda platform, profile_name=None: object()
     
             profile_cfg = GatewayConfig(multiplex_profiles=False)
             profile_cfg.platforms = {
    ```

    ```diff
    @@ -383,6 +523,84 @@ async def disconnect(self):
         def test_port_binding_set_covers_known_listeners(self):
             from gateway.run import _PORT_BINDING_PLATFORM_VALUES
             # Every adapter that binds a TCP port must be in the guard set.
    -        for p in ("webhook", "api_server", "msgraph_webhook", "feishu",
    -                  "wecom_callback", "bluebubbles", "sms"):
    +        for p in (
    +            "webhook",
    +            "api_server",
    +            "msgraph_webhook",
    +            "feishu",
    +            "wecom_callback",
    +            "bluebubbles",
    +            "sms",
    +            "whatsapp_cloud",
    +            "line",
    +        ):
                 assert p in _PORT_BINDING_PLATFORM_VALUES
    +
    +
    +
    +class TestFeishuPortBindingConditional:
    +    """Feishu websocket mode does NOT bind a port; only webhook mode does (#52563)."""
    +
    +    @pytest.mark.asyncio
    +    async def test_feishu_websocket_mode_not_rejected(self, monkeypatch):
    +        """Feishu in websocket mode (the default) should NOT raise MultiplexConfigError."""
    +        from gateway.run import MultiplexConfigError
    +        from gateway.config import GatewayConfig, Platform, PlatformConfig
    +
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = GatewayConfig(multiplex_profiles=True)
    +        runner._profile_adapters = {}
    +
    +        reviewer_cfg = GatewayConfig(multiplex_profiles=True)
    +        reviewer_cfg.platforms = {
    +            Platform.FEISHU: PlatformConfig(
    +                enabled=True,
    +                extra={"app_id": "cli_xxx", "app_secret": "sec", "connection_mode": "websocket"},
    +            ),
    +        }
    +        monkeypatch.setattr("gateway.config.load_gateway_config", lambda: reviewer_cfg)
    +        monkeypatch.setattr(runner, "_create_adapter", lambda p, c: None)
    +
    +        connected = await runner._start_one_profile_adapters("reviewer", "/tmp/x", {})
    +        assert connected == 0  # no error, just nothing connected
    +
    +    @pytest.mark.asyncio
    +    async def test_feishu_webhook_mode_raises(self, monkeypatch):
    +        """Feishu in webhook mode binds a port and should raise MultiplexConfigError."""
    +        from gateway.run import MultiplexConfigError
    +        from gateway.config import GatewayConfig, Platform, PlatformConfig
    +
    +        runner = GatewayRunner.__new__(GatewayRunner)
    +        runner.config = GatewayConfig(multiplex_profiles=True)
    +        runner._profile_adapters = {}
    +
    +        reviewer_cfg = GatewayConfig(multiplex_profiles=True)
    +        reviewer_cfg.platforms = {
    +            Platform.FEISHU: PlatformConfig(
    +                enabled=True,
    +                extra={"app_id": "cli_xxx", "app_secret": "sec", "connection_mode": "webhook"},
    +            ),
    +        }
    +        monkeypatch.setattr("gateway.config.load_gateway_config", lambda: reviewer_cfg)
    +
    +        with pytest.raises(MultiplexConfigError) as ei:
    +            await runner._start_one_profile_adapters("reviewer", "/tmp/x", {})
    +        assert "feishu" in str(ei.value)
    +
    +    def test_platform_binds_port_helper(self):
    +        """Unit test for _platform_binds_port helper."""
    +        from gateway.run import _platform_binds_port
    +
    +        # Non-port-binding platform
    +        assert _platform_binds_port("telegram", {}) is False
    +
    +        # Unconditional port-binding platform
    +        assert _platform_binds_port("webhook", {}) is True
    +        assert _platform_binds_port("api_server", {}) is True
    +
    +        # Feishu: websocket = no port binding
    +        assert _platform_binds_port("feishu", {"connection_mode": "websocket"}) is False
    +        assert _platform_binds_port("feishu", {}) is False  # default is websocket
    +
    +        # Feishu: webhook = port binding
    +        assert _platform_binds_port("feishu", {"connection_mode": "webhook"}) is True
    ```
  - `tests/gateway/test_multiplex_profile_authz.py`

    ```diff
    @@ -139,6 +139,56 @@ def test_secondary_allowlist_dm_behavior_ignores_unauthorized(monkeypatch):
         assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "ignore"
     
     
    +def test_adapter_auth_check_stamps_secondary_profile(monkeypatch):
    +    """The adapter auth-check callback must stamp its own secondary profile.
    +
    +    Regression for the gap where ``_make_adapter_auth_check`` built a
    +    profile-less ``SessionSource``, so a secondary adapter's external-context
    +    authorization (e.g. Slack/Discord thread-reply lookups) silently
    +    resolved the *active* profile's allowlist scope instead of its own.
    +    """
    +    from gateway.run import GatewayRunner
    +
    +    _clear_auth_env(monkeypatch)
    +
    +    runner = object.__new__(GatewayRunner)
    +    runner.config = GatewayConfig(multiplex_profiles=True)
    +
    +    captured: dict = {}
    +
    +    def fake_is_user_authorized(source):
    +        captured["profile"] = source.profile
    +        return True
    +
    +    runner._is_user_authorized = fake_is_user_authorized
    +
    +    check = runner._make_adapter_auth_check(Platform.WECOM, profile_name="coder")
    +    assert check("some-user", "dm", "dm-chat") is True
    +    assert captured["profile"] == "coder"
    +
    +
    +def test_adapter_auth_check_defaults_to_active_profile(monkeypatch):
    +    """Primary-adapter callbacks (no profile_name) still resolve the active profile."""
    +    from gateway.run import GatewayRunner
    +
    +    _clear_auth_env(monkeypatch)
    +
    +    runner = object.__new__(GatewayRunner)
    +    runner.config = GatewayConfig(multiplex_profiles=True)
    +
    +    captured: dict = {}
    +
    +    def fake_is_user_authorized(source):
    +        captured["profile"] = source.profile
    +        return True
    +
    +    runner._is_user_authorized = fake_is_user_authorized
    +
    +    check = runner._make_adapter_auth_check(Platform.WECOM)
    +    assert check("some-user", "dm", "dm-chat") is True
    +    assert captured["profile"] is None
    +
    +
     def test_secondary_open_policy_fails_startup_guard(monkeypatch):
         """Secondary profiles must pass the same open-policy startup guard."""
         from gateway.run import _own_policy_open_startup_violation
    ```
  - `tests/gateway/test_status_command.py`

    ```diff
    @@ -672,6 +672,92 @@ async def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path
         assert f"**Home:** `{profile_home}`" in result
     
     
    [redacted:email]
    +async def test_profile_command_reports_source_stamped_profile(monkeypatch, tmp_path):
    +    """On a multiplexed gateway, /profile reports the profile SERVING the
    +    source (source.profile — URL prefix / per-credential adapter / room map),
    +    not the multiplexer's active profile, which is always the default and
    +    made /profile answer "default" in every persona chat."""
    +    hermes_home = tmp_path / ".hermes"
    +    profile_home = hermes_home / "profiles" / "milo"
    +    profile_home.mkdir(parents=True)
    +
    +    session_entry = SessionEntry(
    +        session_key=build_session_key(_make_source()),
    +        session_id="sess-1",
    +        created_at=datetime.now(),
    +        updated_at=datetime.now(),
    +        platform=Platform.TELEGRAM,
    +        chat_type="dm",
    +    )
    +    runner = _make_runner(session_entry)
    +    runner.config.multiplex_profiles = True
    +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +
    +    event = _make_event("/profile")
    +    event.source.profile = "milo"
    +
    +    result = await runner._handle_profile_command(event)
    +
    +    assert "**Profile:** `milo`" in result
    +    assert f"**Home:** `{profile_home}`" in result
    +
    +
    [redacted:email]
    +async def test_profile_command_ignores_stamp_when_multiplexing_off(monkeypatch, tmp_path):
    +    """Without ``gateway.multiplex_profiles`` a stamped source is ignored:
    +    /profile keeps reporting the active profile and the default home,
    +    mirroring the multiplex gating in ``_run_agent`` and
    +    ``_reset_notice_session_info``."""
    +    hermes_home = tmp_path / ".hermes"
    +    profile_home = hermes_home / "profiles" / "milo"
    +    profile_home.mkdir(parents=True)
    +
    +    session_entry = SessionEntry(
    +        session_key=build_session_key(_make_source()),
    +        session_id="sess-1",
    +        created_at=datetime.now(),
    +        updated_at=datetime.now(),
    +        platform=Platform.TELEGRAM,
    +        chat_type="dm",
    +    )
    +    runner = _make_runner(session_entry)
    +    assert runner.config.multiplex_profiles is False
    +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +
    +    event = _make_event("/profile")
    +    event.source.profile = "milo"
    +
    +    result = await runner._handle_profile_command(event)
    +
    +    assert "**Profile:** `default`" in result
    +    assert f"**Home:** `{hermes_home}`" in result
    +
    +
    [redacted:email]
    +async def test_profile_command_unstamped_source_unchanged(monkeypatch, tmp_path):
    +    """Single-profile behavior is untouched: an unstamped source reports the
    +    active profile and the default home."""
    +    hermes_home = tmp_path / ".hermes"
    +    hermes_home.mkdir()
    +
    +    session_entry = SessionEntry(
    +        session_key=build_session_key(_make_source()),
    +        session_id="sess-1",
    +        created_at=datetime.now(),
    +        updated_at=datetime.now(),
    +        platform=Platform.TELEGRAM,
    +        chat_type="dm",
    +    )
    +    runner = _make_runner(session_entry)
    +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +
    +    result = await runner._handle_profile_command(_make_event("/profile"))
    +
    +    assert "**Profile:** `default`" in result
    +    assert f"**Home:** `{hermes_home}`" in result
    +
    +
     @pytest.mark.asyncio
     async def test_post_delivery_callback_generation_snapshot_happens_after_bind():
         """Regression: the callback_generation snapshot in _process_message_background
    ```
  - `tests/hermes_cli/test_web_server_messaging_profiles.py`

    ```diff
    @@ -228,3 +228,149 @@ def test_scoped_clear_env_removes_from_target_only(
                 encoding="utf-8"
             )
             assert "TELEGRAM_BOT_TOKEN=[redacted:secret-assignment]" in root_env
    +
    +
    +def _enable_multiplex(default_home):
    +    (default_home / "config.yaml").write_text(
    +        yaml.safe_dump({"gateway": {"multiplex_profiles": True}}),
    +        encoding="utf-8",
    +    )
    +
    +
    +class TestMultiplexPortBindingGuard:
    +    """Enabling a port-binding channel on a secondary multiplexed profile
    +    must be rejected BEFORE anything is persisted.
    +
    +    The gateway fail-fasts with ``MultiplexConfigError`` when a secondary
    +    profile enables a port-binding platform under
    +    ``gateway.multiplex_profiles`` — but the dashboard used to persist that
    +    exact config, so the next gateway start died for EVERY profile (#62791).
    +    """
    +
    +    @pytest.fixture(autouse=True)
    +    def _no_multiplex_env_override(self, monkeypatch):
    +        # The operator env override must not leak into these tests: the
    +        # multiplex flag under test comes from the default profile's config.
    +        monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
    +
    +    def test_rejects_every_port_binding_platform_on_secondary(
    +        self, client, isolated_profiles
    +    ):
    +        from gateway.config import PORT_BINDING_PLATFORM_VALUES
    +
    +        _enable_multiplex(isolated_profiles["default"])
    +        assert PORT_BINDING_PLATFORM_VALUES  # guard set must not be empty
    +        for platform_id in sorted(PORT_BINDING_PLATFORM_VALUES):
    +            resp = client.put(
    +                f"/api/messaging/platforms/{platform_id}",
    +                params={"profile": "worker_alpha"},
    +                json={"enabled": True},
    +            )
    +            assert resp.status_code == 409, platform_id
    +            assert "default profile" in resp.json()["detail"]
    +
    +    def test_body_profile_target_is_also_guarded(self, client, isolated_profiles):
    +        _enable_multiplex(isolated_profiles["default"])
    +        resp = client.put(
    +            "/api/messaging/platforms/api_server",
    +            json={"enabled": True, "profile": "worker_alpha"},
    +        )
    +        assert resp.status_code == 409
    +
    +    def test_rejected_request_leaves_env_and_config_untouched(
    +        self, client, isolated_profiles
    +    ):
    +        _enable_multiplex(isolated_profiles["default"])
    +        worker_home = isolated_profiles["worker_alpha"]
    +        env_before = (worker_home / ".env").read_text(encoding="utf-8")
    +        cfg_before = (worker_home / "config.yaml").read_text(encoding="utf-8")
    +
    +        catalog = client.get(
    +            "/api/messaging/platforms", params={"profile": "worker_alpha"}
    +        ).json()
    +        api_server = next(p for p in catalog["platforms"] if p["id"] == "api_server")
    +        env = {f["key"]: "rejected-value" for f in api_server["env_vars"][:1]}
    +
    +        resp = client.put(
    +            "/api/messaging/platforms/api_server",
    +            params={"profile": "worker_alpha"},
    +            json={"enabled": True, "env": env},
    +        )
    +
    +        assert resp.status_code == 409
    +        assert (worker_home / ".env").read_text(encoding="utf-8") == env_before
    +        assert (worker_home / "config.yaml").read_text(encoding="utf-8") == cfg_before
    +
    +    def test_default_profile_still_allowed_with_multiplex_on(
    +        self, client, isolated_profiles
    +    ):
    +        _enable_multiplex(isolated_profiles["default"])
    +        resp = client.put(
    +            "/api/messaging/platforms/api_server",
    +            params={"profile": "default"},
    +            json={"enabled": True},
    +        )
    +        assert resp.status_code == 200
    +        cfg = yaml.safe_load(
    +            (isolated_profiles["default"] / "config.yaml").read_text()
    +        )
    +        assert cfg["platforms"]["api_server"]["enabled"] is True
    +
    +    def test_secondary_allowed_when_multiplex_off(self, client, isolated_profiles):
    +        # Fixture default config is {} — multiplexing disabled.
    +        resp = client.put(
    +            "/api/messaging/platforms/api_server",
    +            params={"profile": "worker_alpha"},
    +            json={"enabled": True},
    +        )
    +        assert resp.status_code == 200
    +        cfg = yaml.safe_load(
    +            (isolated_profiles["worker_alpha"] / "config.yaml").read_text()
    +        )
    +        assert cfg["platforms"]["api_server"]["enabled"] is True
    +
    +    def test_secondary_can_disable_and_clear_invalid_config(
    +        self, client, isolated_profiles
    +    ):
    +        _enable_multiplex(isolated_profiles["default"])
    +        worker_home = isolated_profiles["worker_alpha"]
    +        (worker_home / "config.yaml").write_text(
    +            yaml.safe_dump({"platforms": {"api_server": {"enabled": True}}}),
    +            encoding="utf-8",
    +        )
    +
    +        resp = client.put(
    +            "/api/messaging/platforms/api_server",
    +            params={"profile": "worker_alpha"},
    +            json={"enabled": False},
    +        )
    +        assert resp.status_code == 200
    +        cfg = yaml.safe_load((worker_home / "config.yaml").read_text())
    +        assert cfg["platforms"]["api_server"]["enabled"] is False
    +
    +        catalog = client.get(
    +            "/api/messaging/platforms", params={"profile": "worker_alpha"}
    +        ).json()
    +        api_server = next(p for p in catalog["platforms"] if p["id"] == "api_server")
    +        if api_server["env_vars"]:
    +            resp = client.put(
    +                "/api/messaging/platforms/api_server",
    +                params={"profile": "worker_alpha"},
    +                json={"clear_env": [api_server["env_vars"][0]["key"]]},
    +            )
    +            assert resp.status_code == 200
    +
    +    def test_non_port_binding_platform_unaffected_on_secondary(
    +        self, client, isolated_profiles
    +    ):
    +        _enable_multiplex(isolated_profiles["default"])
    +        resp = client.put(
    +            "/api/messaging/platforms/telegram",
    +            params={"profile": "worker_alpha"},
    +            json={"enabled": True, "env": {"TELEGRAM_BOT_TOKEN": _VALID_WORKER_BOT_TOKEN}},
    +        )
    +        assert resp.status_code == 200
    +        cfg = yaml.safe_load(
    +            (isolated_profiles["worker_alpha"] / "config.yaml").read_text()
    +        )
    +        assert cfg["platforms"]["telegram"]["enabled"] is True
    ```
  - `website/docs/user-guide/multi-profile-gateways.md`

    ```diff
    @@ -142,18 +142,26 @@ POST http://host:8644/p/coder/webhooks/<route>
     An unknown or unconfigured profile in the prefix returns `404`. Because the one
     shared listener already serves every profile this way, a **secondary profile
     must not enable a port-binding platform itself** — doing so is a config error
    -and the gateway refuses to start, naming the profile and platform:
    +that skips the entire secondary profile while the default and other healthy
    +profiles continue. The warning names the skipped profile and every conflicting
    +platform:
     
     ```
    -Profile 'coder' enables the port-binding platform 'webhook', but
    -gateway.multiplex_profiles is on. ... Remove platforms.webhook from profile
    -'coder's config.yaml (configure it only on the default profile).
    +Skipping secondary profile 'coder' due to port-binding config error: Profile
    +'coder' enables port-binding platform(s) webhook, but gateway.multiplex_profiles
    +is on. ... Remove these platform entries from profile 'coder's config.yaml or
    +configure them only on the default profile.
     ```
     
     Port-binding platforms covered by this rule: `webhook`, `api_server`,
    -`msgraph_webhook`, `feishu`, `wecom_callback`, `bluebubbles`, `sms`. Configure
    -any of these **only on the default profile**; every profile is reachable through
    -its `/p/<profile>/` prefix.
    +`msgraph_webhook`, `feishu`, `wecom_callback`, `bluebubbles`, `sms`,
    +`whatsapp_cloud`, `line`. Configure any of these **only on the default profile**;
    +every profile is reachable through its `/p/<profile>/` prefix.
    +
    +Only this shared-listener conflict degrades to a skipped profile. Security
    +configuration errors remain fatal: for example, an `open` own-policy platform
    +without `GATEWAY_ALLOW_ALL_USERS` or its platform-specific allow-all opt-in
    +still aborts gateway startup rather than silently dropping the unsafe profile.
     
     #### 3. Per-credential platforms still need their own token per profile
     
    ```
- **#52587: fix(gateway): allow Feishu websocket mode in multiplex profiles**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#65700`, `#62803`, `#52563`
- **#58016: fix(gateway): allow feishu websocket mode in multiplexed profiles (#57896)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#65700`, `#52587`

## Fix status

- Merged to main in PR #65700 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
- Subsystem: `gateway`
