Background task scoping errors with gateway multiplexing
Cluster 60726. Data as of
Nothing open on GitHub. Fix merged in #65721 on 16 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #65721. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #65721. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs concern UnscopedSecretError failures in background tasks when gateway multiplexing is enabled, and attempts to fix profile runtime scope installation in that context. Related fixes also address port-binding degradation and other multiplexed gateway concerns.
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": [redacted:secret-assignment] + ) + 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
-
-
#65721 fix(gateway): run background tasks under the profile runtime scope on multiplexed gateways (#60746 salvage)
files:
gateway/run.py,tests/gateway/test_multiplex_background_task_scope.pyShow the diff
-
gateway/run.py@@ -13763,6 +13763,33 @@ async def _run_background_task( event_message_id: Optional[str] = None, media_urls: Optional[List[str]] = None, media_types: Optional[List[str]] = None, + ) -> None: + """Profile-scoping wrapper around the background agent task. + + When multiplexing is active, resolve the inbound source's profile and + run the whole task inside ``_profile_runtime_scope`` so credentials + resolve from that profile's secret scope. Mirrors the pattern in + ``_run_agent``. + """ + if not getattr(getattr(self, "config", None), "multiplex_profiles", False): + return await self._run_background_task_inner( + prompt, source, task_id, event_message_id, media_urls, media_types, + ) + + profile_home = self._resolve_profile_home_for_source(source) + with _profile_runtime_scope(profile_home): + return await self._run_background_task_inner( + prompt, source, task_id, event_message_id, media_urls, media_types, + ) + + async def _run_background_task_inner( + self, + prompt: str, + source: "SessionSource", + task_id: str, + event_message_id: Optional[str] = None, + media_urls: Optional[List[str]] = None, + media_types: Optional[List[str]] = None, ) -> None: """Execute a background agent task and deliver the result to the chat.""" from run_agent import AIAgent -
tests/gateway/test_multiplex_background_task_scope.py@@ -0,0 +1,88 @@ +"""Regression: background tasks respect profile secret scope when multiplexing. + +Issue #60726: /background spawns _run_background_task as a fire-and-forget +asyncio task with no profile scope, so _resolve_session_agent_runtime()'s +credential reads raise UnscopedSecretError when multiplex_profiles is on. +The fix wraps the task body in _profile_runtime_scope, mirroring _run_agent. +""" +import asyncio +from pathlib import Path +from unittest import mock + +from gateway.config import GatewayConfig +from gateway.run import GatewayRunner + + +def _make_runner(multiplex: bool) -> GatewayRunner: + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(multiplex_profiles=multiplex) + return runner + + +class TestBackgroundTaskProfileScope: + """_run_background_task installs _profile_runtime_scope when multiplexing is active.""" + + def test_wraps_in_profile_scope_when_multiplex_active(self): + runner = _make_runner(multiplex=True) + inner = mock.AsyncMock(return_value=None) + runner._run_background_task_inner = inner + + source = mock.MagicMock() + source.profile = "test_profile" + + with mock.patch.object( + GatewayRunner, + "_resolve_profile_home_for_source", + return_value=Path("/fake/profile"), + ), mock.patch("gateway.run._profile_runtime_scope") as scope: + scope.return_value.__enter__ = mock.MagicMock() + scope.return_value.__exit__ = mock.MagicMock(return_value=False) + asyncio.run( + runner._run_background_task( + prompt="test", source=source, task_id="bg_test" + ) + ) + + scope.assert_called_once_with(Path("/fake/profile")) + inner.assert_awaited_once() + + def test_calls_inner_directly_when_multiplex_disabled(self): + runner = _make_runner(multiplex=False) + inner = mock.AsyncMock(return_value=None) + runner._run_background_task_inner = inner + + with mock.patch("gateway.run._profile_runtime_scope") as scope: + asyncio.run( + runner._run_background_task( + prompt="test", source=mock.MagicMock(), task_id="bg_test" + ) + ) + + scope.assert_not_called() + inner.assert_awaited_once() + + def test_inner_receives_all_arguments(self): + runner = _make_runner(multiplex=True) + inner = mock.AsyncMock(return_value=None) + runner._run_background_task_inner = inner + source = mock.MagicMock() + + with mock.patch.object( + GatewayRunner, + "_resolve_profile_home_for_source", + return_value=Path("/fake/profile"), + ), mock.patch("gateway.run._profile_runtime_scope") as scope: + scope.return_value.__enter__ = mock.MagicMock() + scope.return_value.__exit__ = mock.MagicMock(return_value=False) + asyncio.run( + runner._run_background_task( + prompt="p", + source=source, + task_id="t", + event_message_id="m1", + media_urls=["u"], + media_types=["image"], + ) + ) + + inner.assert_awaited_once_with("p", source, "t", "m1", ["u"], ["image"])
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#60746 fix(gateway): install _profile_runtime_scope in _run_background_task when multiplexing is active · merged via another change →
#65721,#65700,#60726
Closed issues · 1
- Closed#60726Background task () fails with UnscopedSecretError when multiplexing is on
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.50: 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 Lab