From ede7e316365d6922a2fd8e5ca3412728c1819d00 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:04:28 -0700 Subject: [PATCH] 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. --- agent/auxiliary_client.py | 43 ++++++++++++++++- tests/agent/test_auxiliary_client.py | 70 +++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6326cb81e9d..eecddb62764 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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: diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index d793c58ccb5..49c75add205 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -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"