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
- 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
Open PRs0
Fixes on record
-
#65700 fix(gateway): multiplex wave 2 — port-binding degradation, dashboard guard, scoped picker//profile/api_server (7-PR cluster salvage)
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.mdShow 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
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.00means 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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #65700
- Closed issues
- 2
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 16 Jul 2026