mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
fix(auxiliary): gate main api_key inheritance on same-host aux base_url
Follow-up to the #55911 salvage: inherit model.api_key only when the aux base_url resolves to the same hostname as the main model's base_url (runtime override or config). A misconfigured aux endpoint on a different host keeps the fail-safe no-key-required placeholder instead of leaking the main credential cross-host.
This commit is contained in:
parent
8e09afda27
commit
ede7e31636
2 changed files with 110 additions and 3 deletions
|
|
@ -2032,6 +2032,47 @@ def _read_main_api_key() -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _read_main_base_url() -> str:
|
||||
"""Read the main model's base_url from the runtime override or config.
|
||||
|
||||
Same override-then-config pattern as ``_read_main_api_key``.
|
||||
"""
|
||||
override = _RUNTIME_MAIN_BASE_URL
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.strip()
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
base = model_cfg.get("base_url", "")
|
||||
if isinstance(base, str) and base.strip():
|
||||
return base.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _read_main_api_key_if_same_host(aux_base_url: str) -> str:
|
||||
"""Return the main api_key only when *aux_base_url* points at the same
|
||||
host as the main model's base_url.
|
||||
|
||||
The #9318 use case is an auxiliary task sharing the main model's
|
||||
self-hosted gateway (same host, different model) with an empty per-task
|
||||
api_key. Inheriting unconditionally would send the main credential to
|
||||
ANY host a misconfigured aux base_url names — a cross-host credential
|
||||
leak. A host mismatch keeps the previous fail-safe behavior
|
||||
(``no-key-required`` → 401).
|
||||
"""
|
||||
aux_host = base_url_hostname(aux_base_url)
|
||||
if not aux_host:
|
||||
return ""
|
||||
main_host = base_url_hostname(_read_main_base_url())
|
||||
if not main_host or aux_host != main_host:
|
||||
return ""
|
||||
return _read_main_api_key()
|
||||
|
||||
|
||||
# Process-local override set by AIAgent at session/turn start. Single-threaded
|
||||
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
|
||||
_RUNTIME_MAIN_PROVIDER: str = ""
|
||||
|
|
@ -4271,7 +4312,7 @@ def resolve_provider_client(
|
|||
custom_key = (
|
||||
(explicit_api_key or "").strip()
|
||||
or os.getenv("OPENAI_API_KEY", "").strip()
|
||||
or _read_main_api_key()
|
||||
or _read_main_api_key_if_same_host(custom_base)
|
||||
or "no-key-required" # local servers don't need auth
|
||||
)
|
||||
if not custom_base:
|
||||
|
|
|
|||
|
|
@ -5196,18 +5196,26 @@ class TestCustomEndpointApiKeyInheritance:
|
|||
Without this fix, users on self-hosted gateways who share the same
|
||||
endpoint+credentials for both the main model and auxiliary tasks get 401
|
||||
auth errors because the placeholder key is sent instead of the real one.
|
||||
|
||||
Inheritance is host-gated: the main key is only inherited when the aux
|
||||
base_url points at the same host as the main model's base_url, so a
|
||||
misconfigured aux endpoint cannot leak the main credential cross-host.
|
||||
"""
|
||||
|
||||
def test_inherits_main_api_key_when_aux_key_empty(self, monkeypatch):
|
||||
"""RED→GREEN: explicit_api_key is None, OPENAI_API_KEY unset →
|
||||
model.api_key from config.yaml must be used."""
|
||||
model.api_key from config.yaml must be used (same-host gateway)."""
|
||||
import agent.auxiliary_client as ac
|
||||
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
|
||||
fake_config = {
|
||||
"model": {"api_key": "sk-main-config-key", "default": "main-model"}
|
||||
"model": {
|
||||
"api_key": "sk-main-config-key",
|
||||
"base_url": "https://gw.example.com/v1",
|
||||
"default": "main-model",
|
||||
}
|
||||
}
|
||||
captured: dict = {}
|
||||
|
||||
|
|
@ -5293,6 +5301,7 @@ class TestCustomEndpointApiKeyInheritance:
|
|||
return MagicMock()
|
||||
|
||||
with patch.object(ac, "_RUNTIME_MAIN_API_KEY", "sk-runtime-key"), \
|
||||
patch.object(ac, "_RUNTIME_MAIN_BASE_URL", "https://gw.example.com/v1"), \
|
||||
patch("hermes_cli.config.load_config", return_value={"model": {}}), \
|
||||
patch.object(ac, "_create_openai_client", side_effect=_capture_create):
|
||||
client, model = resolve_provider_client(
|
||||
|
|
@ -5303,3 +5312,60 @@ class TestCustomEndpointApiKeyInheritance:
|
|||
)
|
||||
|
||||
assert captured.get("api_key") == "sk-runtime-key"
|
||||
|
||||
def test_cross_host_aux_endpoint_does_not_inherit_main_key(self, monkeypatch):
|
||||
"""An aux base_url on a DIFFERENT host than the main model must NOT
|
||||
inherit model.api_key — that would leak the main credential to
|
||||
whatever host a misconfigured aux endpoint names. Falls back to the
|
||||
fail-safe no-key-required placeholder instead."""
|
||||
import agent.auxiliary_client as ac
|
||||
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
fake_config = {
|
||||
"model": {
|
||||
"api_key": "sk-main-config-key",
|
||||
"base_url": "https://gw.example.com/v1",
|
||||
}
|
||||
}
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value=fake_config), \
|
||||
patch.object(ac, "_create_openai_client", side_effect=_capture_create):
|
||||
client, model = resolve_provider_client(
|
||||
"custom",
|
||||
model="test-model",
|
||||
explicit_base_url="https://other-host.example.net/v1",
|
||||
explicit_api_key=None,
|
||||
)
|
||||
|
||||
assert captured.get("api_key") == "no-key-required"
|
||||
|
||||
def test_no_main_base_url_does_not_inherit_main_key(self, monkeypatch):
|
||||
"""When the main model has no base_url (e.g. a first-class provider),
|
||||
there is no 'same gateway' to match — do not inherit the key."""
|
||||
import agent.auxiliary_client as ac
|
||||
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
fake_config = {"model": {"api_key": "sk-main-config-key"}}
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_create(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
with patch("hermes_cli.config.load_config", return_value=fake_config), \
|
||||
patch.object(ac, "_create_openai_client", side_effect=_capture_create):
|
||||
client, model = resolve_provider_client(
|
||||
"custom",
|
||||
model="test-model",
|
||||
explicit_base_url="https://gw.example.com/v1",
|
||||
explicit_api_key=None,
|
||||
)
|
||||
|
||||
assert captured.get("api_key") == "no-key-required"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue