Feishu wrongly flagged as port-binding in multiplex gateway

NousResearch/hermes-agent · cluster 52563 · gateway

Data as of

Nothing open on GitHub. Fix merged in #65700. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #65700. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #65700. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #65700 fix(gateway): multiplex wave 2 — port-binding degradation, dashboard guard, scoped picker//profile/api_server (7-PR cluster salvage)

    merged to main

    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

    Show the diff
    • gateway/config.py
      @@ -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
      @@ -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
      @@ -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.
      @@ -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(
      @@ -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():
      @@ -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)
      @@ -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:
      @@ -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.
       
      @@ -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,
      @@ -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
      @@ -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),
      @@ -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
               (
      @@ -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:
      @@ -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).
      @@ -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
      @@ -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."""
      @@ -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
      @@ -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]] = {
      @@ -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
      @@ -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):
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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):
      @@ -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
      @@ -1,4 +1,6 @@
       """Phase 3: secondary-profile adapter registry + same-token conflict detection."""
      +import logging
      +
       import pytest
       
       from gateway.run import GatewayRunner
      @@ -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)
      @@ -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):
      @@ -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 = {
      @@ -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 = {
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
       

3 PRs closed without merging

From the close comments: merged via another change 2 · done elsewhere 1

  • #52587 fix(gateway): allow Feishu websocket mode in multiplex profiles · merged via another change → #65700, #62803, #52563
  • #58016 fix(gateway): allow feishu websocket mode in multiplexed profiles (#57896) · merged via another change → #65700, #52587
  • #61437 fix(gateway): remove feishu from port-binding platform set (fixes #52563) · done elsewhere → #52563
Closed issues · 2
  • Closed#52563Feishu misclassified as port-binding platform, breaking multiplexed Feishu profiles
  • Closed#57896Multiplexing gateway: feishu websocket mode should not be treated as port-binding
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.36: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#65700
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
16 Jul 2026

For your agent

The same facts, typed.