mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(gateway): scope default-listener api_server requests under multiplex
Rebuilt from PR #61283 onto the /p/<profile>/ 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
This commit is contained in:
parent
fef0b2d600
commit
6ff65c4d20
2 changed files with 101 additions and 1 deletions
|
|
@ -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/<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
|
||||
|
|
|
|||
81
tests/gateway/test_api_server_multiplex_secret_scope.py
Normal file
81
tests/gateway/test_api_server_multiplex_secret_scope.py
Normal file
|
|
@ -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
|
||||
|
||||
|
||||
@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/<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
|
||||
Loading…
Add table
Add a link
Reference in a new issue