Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1224,16 +1224,20 @@ def init_agent(
_fb_entries = [fallback_model]
_fb_resolved = False
for _fb in _fb_entries:
_fb_explicit_key = (_fb.get("api_key") or "").strip() or None
if not _fb_explicit_key:
_fb_key_env = (_fb.get("key_env") or _fb.get("api_key_env") or "").strip()
if _fb_key_env:
_fb_explicit_key = os.getenv(_fb_key_env, "").strip() or None
_fb_client, _fb_model = resolve_provider_client(
_fb["provider"], model=_fb["model"], raw_codex=True,
explicit_base_url=_fb.get("base_url"),
explicit_api_key=_fb_explicit_key,
)
try:
from hermes_cli.fallback_config import resolve_entry_api_key
_fb_explicit_key = resolve_entry_api_key(_fb)
_fb_client, _fb_model = resolve_provider_client(
_fb["provider"], model=_fb["model"], raw_codex=True,
explicit_base_url=_fb.get("base_url"),
explicit_api_key=_fb_explicit_key,
)
except Exception as _fb_exc:
logger.debug(
"Init-time fallback entry %s failed: %s",
_fb.get("provider"), _fb_exc,
)
continue
if _fb_client is not None:
agent.provider = _fb["provider"]
agent.model = _fb_model or _fb["model"]
Expand Down
17 changes: 9 additions & 8 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4722,14 +4722,15 @@ def _try_configured_fallback_for_unavailable_client(


def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key from a fallback-chain entry."""
explicit = str(entry.get("api_key") or "").strip()
if explicit:
return explicit
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
return None
"""Resolve inline or env-backed API key from a fallback-chain entry.

Delegates to the centralized, secret-scope-aware resolver so this path
doesn't leak another profile's credential via a raw ``os.getenv`` under
gateway multiplexing (see ``hermes_cli.fallback_config.resolve_entry_api_key``).
"""
from hermes_cli.fallback_config import resolve_entry_api_key

return resolve_entry_api_key(entry)
Comment thread
JoaoMarcos44 marked this conversation as resolved.


def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
Expand Down
14 changes: 6 additions & 8 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1788,19 +1788,17 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# Pass base_url and api_key from fallback config so custom
# endpoints (e.g. Ollama Cloud) resolve correctly instead of
# falling through to OpenRouter defaults.
from hermes_cli.fallback_config import resolve_entry_api_key

fb_base_url_hint = (fb.get("base_url") or "").strip() or None
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
if not fb_api_key_hint:
# key_env and api_key_env are both documented aliases (see
# _normalize_custom_provider_entry in hermes_cli/config.py).
fb_key_env = (fb.get("key_env") or fb.get("api_key_env") or "").strip()
if fb_key_env:
fb_api_key_hint = os.getenv(fb_key_env, "").strip() or None
fb_api_key_hint = resolve_entry_api_key(fb)
# For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env
# when no explicit key is in the fallback config. Host match
# (not substring) — see GHSA-76xc-57q6-vm5m.
if fb_base_url_hint and base_url_host_matches(fb_base_url_hint, "ollama.com") and not fb_api_key_hint:
fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None
from agent.secret_scope import get_secret

fb_api_key_hint = get_secret("OLLAMA_API_KEY") or None
fb_client, _resolved_fb_model = resolve_provider_client(
fb_provider, model=fb_model, raw_codex=True,
explicit_base_url=fb_base_url_hint,
Expand Down
13 changes: 11 additions & 2 deletions hermes_cli/fallback_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import os
from typing import Any


Expand All @@ -19,6 +18,14 @@ def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None:
holding the key; ``api_key_env`` accepted as an alias). Returns None when
neither yields a non-empty value, letting ``resolve_runtime_provider``
fall through to the provider's standard credential resolution.

``key_env`` is resolved through ``agent.secret_scope.get_secret`` rather
than a raw ``os.getenv`` — in a multiplexed gateway a bare env read would
ignore the active profile's scope and can return another profile's
credential. ``get_secret`` already implements the right fallback: it
reads ``os.environ`` when there's no active multiplexed scope (matching
prior single-profile behavior), and fails closed only when multiplexing
is active with no scope installed.
"""
if not isinstance(entry, dict):
return None
Expand All @@ -27,7 +34,9 @@ def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None:
return inline
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
from agent.secret_scope import get_secret

return (get_secret(key_env) or "").strip() or None
return None


Expand Down
19 changes: 19 additions & 0 deletions tests/hermes_cli/test_fallback_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for hermes_cli/fallback_config.py — fallback entry API-key resolution."""

from agent.secret_scope import reset_secret_scope, set_secret_scope
from hermes_cli.fallback_config import resolve_entry_api_key


Expand Down Expand Up @@ -38,3 +39,21 @@ def test_whitespace_inline_key_falls_through_to_env(self, monkeypatch):
monkeypatch.setenv("FB_KEY", "env-key")
entry = {"api_key": " ", "key_env": "FB_KEY"}
assert resolve_entry_api_key(entry) == "env-key"

def test_key_env_resolves_from_active_secret_scope_not_raw_env(self, monkeypatch):
# Multiplexed gateway: os.environ holds another profile's key, but the
# active per-turn secret scope holds this profile's key. The scoped
# value must win — a raw os.getenv() would leak the other profile's
# credential (issue #74311).
monkeypatch.setenv("FB_KEY", "fake-other-profile-key")
token = set_secret_scope({"FB_KEY": "fake-active-profile-key"})
try:
assert resolve_entry_api_key({"key_env": "FB_KEY"}) == "fake-active-profile-key"
finally:
reset_secret_scope(token)

def test_key_env_falls_back_to_env_when_no_active_scope(self, monkeypatch):
# Non-multiplexed / single-profile behavior must be unchanged: with no
# secret scope installed, resolution still reads os.environ.
monkeypatch.setenv("FB_KEY", "env-key")
assert resolve_entry_api_key({"key_env": "FB_KEY"}) == "env-key"
Loading