From 6ff65c4d201ea2979da91c37a7106b7d48571809 Mon Sep 17 00:00:00 2001 From: giggling-ginger <110955495+giggling-ginger@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:40:10 -0700 Subject: [PATCH] fix(gateway): scope default-listener api_server requests under multiplex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt from PR #61283 onto the /p// routing world (7aa21e336): _profile_scope(None) now enters the DEFAULT profile's runtime scope when multiplexing is active instead of returning nullcontext(). api_server is a port-binding platform living on the default profile, so plain requests (no /p/ prefix) are the primary path — with fail-closed get_secret they crashed with UnscopedSecretError on the first credential read (#61276). All three wrapped call sites (chat-completions executor, /v1/runs agent construction and _run_sync) inherit the fix through the one seam. Single-profile gateways keep the no-op. Regression tests ported from the original PR to the _profile_scope seam. Fixes #61276 --- gateway/platforms/api_server.py | 21 ++++- .../test_api_server_multiplex_secret_scope.py | 81 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_api_server_multiplex_secret_scope.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index ef3554aabf4e..5414b3ec0487 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1284,8 +1284,27 @@ class APIServerAdapter(BasePlatformAdapter): @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//`` 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 diff --git a/tests/gateway/test_api_server_multiplex_secret_scope.py b/tests/gateway/test_api_server_multiplex_secret_scope.py new file mode 100644 index 000000000000..c30d868ffb15 --- /dev/null +++ b/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//`` 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 + + +@pytest.fixture(autouse=True) +def _reset_multiplex(): + ss.set_multiplex_active(False) + yield + ss.set_multiplex_active(False) + + +@pytest.fixture +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// 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