Skip to content
Open
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
28 changes: 26 additions & 2 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3942,6 +3942,26 @@ def _try_configured_fallback_for_unavailable_client(
return _try_configured_fallback_chain(task, explicit, reason="provider unavailable")


def _resolve_config_key_env(key_env: str) -> str:
"""Resolve a config-declared credential ``key_env`` var, preferring ``~/.hermes/.env``.

Custom providers, fallback-chain entries, and auxiliary tasks all declare a
``key_env`` naming a credential in ``~/.hermes/.env``. A long-lived
``hermes serve`` (Desktop local gateway) snapshots ``os.environ`` once at
spawn, so a plain ``os.getenv()`` read never sees a key added/edited in
``.env`` mid-session — the request falls through to the ``no-key-required``
placeholder and 401s until the backend is restarted (#67935). Routing
through ``get_env_value_prefer_dotenv()`` lets a fresh ``.env`` value win
over a stale inherited one, matching the credential-pool seeding path.
"""
key_env = (key_env or "").strip()
if not key_env:
return ""
from hermes_cli.config import get_env_value_prefer_dotenv

return (get_env_value_prefer_dotenv(key_env) or "").strip()


def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
"""Resolve inline or env-backed API key via the secret-scope-aware resolver (no raw os.getenv under multiplexing)."""
from hermes_cli.fallback_config import resolve_entry_api_key
Expand Down Expand Up @@ -4235,7 +4255,11 @@ def _named_custom_api_key(custom_entry: Dict[str, Any], provider: str, custom_ba
custom_key: Any = (custom_entry.get("api_key") or "").strip()
custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip()
if not custom_key and custom_key_env:
custom_key = _scoped_key_env(custom_key_env)
# Prefer a fresh ~/.hermes/.env value over a stale os.environ snapshot: a
# long-lived `hermes serve` reads the env once at spawn, so a key added or
# edited in .env mid-session is otherwise never seen and the request 401s
# on the no-key-required placeholder until restart (#67935).
custom_key = _resolve_config_key_env(custom_key_env)
custom_key_cmd = str(custom_entry.get("key_cmd", "") or "").strip()
if custom_key_cmd:
from agent.command_token_source import build_command_token_provider
Expand Down Expand Up @@ -5459,7 +5483,7 @@ def _resolve_task_provider_model(
if not cfg_api_key: # key_env → env var when api_key is not set directly
cfg_key_env = str(task_config.get("key_env") or task_config.get("api_key_env") or "").strip()
if cfg_key_env:
cfg_api_key = _scoped_key_env(cfg_key_env) or None
cfg_api_key = _resolve_config_key_env(cfg_key_env) or None
resolved_api_mode = str(task_config.get("api_mode", "")).strip() or None
# 'auto' is a sentinel ("inherit / auto-detect"), not a model id — leaking it to the wire
# yields a 200 with an error-text body that consumers accept as output. The explicit `model`
Expand Down
103 changes: 103 additions & 0 deletions tests/agent/test_auxiliary_key_env_prefer_dotenv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Regression tests for #67935.

A long-lived ``hermes serve`` (Desktop local gateway) snapshots ``os.environ``
once at spawn. A custom provider's ``key_env`` was resolved via plain
``os.getenv()``, so a key added or rotated in ``~/.hermes/.env`` mid-session
never reached the backend — the request fell through to the ``no-key-required``
placeholder and 401'd until restart.

``agent.auxiliary_client._resolve_config_key_env()`` now routes through
``get_env_value_prefer_dotenv()``, so a fresh ``.env`` value wins over a stale
inherited ``os.environ`` value. These pin that invariant.
"""
from pathlib import Path

import pytest


@pytest.fixture
def isolated_hermes_home(tmp_path, monkeypatch):
"""Point HERMES_HOME at a temp dir and clear the test key from os.environ."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("LONGCAT_API_KEY", raising=False)
# load_env caches by (path, mtime, size); reset so a prior test's cache
# doesn't mask this home's .env.
from hermes_cli import config as _config

_config.invalidate_env_cache()
return home


def _write_env(home: Path, **kwargs) -> None:
(home / ".env").write_text(
"\n".join(f"{k}={v}" for k, v in kwargs.items()) + "\n", encoding="utf-8"
)


def test_resolve_key_env_reads_fresh_dotenv(isolated_hermes_home):
"""Key present only in .env (not os.environ) resolves — the mid-session add."""
from agent.auxiliary_client import _resolve_config_key_env

_write_env(isolated_hermes_home, LONGCAT_API_KEY="sk-from-dotenv")
from hermes_cli import config as _config

_config.invalidate_env_cache()

assert _resolve_config_key_env("LONGCAT_API_KEY") == "sk-from-dotenv"


def test_resolve_key_env_prefers_dotenv_over_stale_environ(
isolated_hermes_home, monkeypatch
):
"""A rotated .env value wins over a stale value inherited in os.environ."""
from agent.auxiliary_client import _resolve_config_key_env

# Backend spawned with an old key baked into os.environ ...
monkeypatch.setenv("LONGCAT_API_KEY", "sk-stale-from-shell")
# ... user rotates it in ~/.hermes/.env mid-session.
_write_env(isolated_hermes_home, LONGCAT_API_KEY="sk-rotated-in-dotenv")
from hermes_cli import config as _config

_config.invalidate_env_cache()

assert _resolve_config_key_env("LONGCAT_API_KEY") == "sk-rotated-in-dotenv"


def test_resolve_key_env_empty_when_unset(isolated_hermes_home):
"""No key anywhere → empty string (caller falls back to no-key-required)."""
from agent.auxiliary_client import _resolve_config_key_env

assert _resolve_config_key_env("LONGCAT_API_KEY") == ""
assert _resolve_config_key_env("") == ""


def test_named_custom_provider_entry_resolves_key_from_dotenv(isolated_hermes_home):
"""The named-custom-provider resolution shape (the #67935 repro config):
an entry declaring ``key_env`` resolves to the live .env key rather than
falling through to the ``no-key-required`` placeholder."""
from agent.auxiliary_client import _resolve_config_key_env

_write_env(isolated_hermes_home, LONGCAT_API_KEY="sk-live-key")
from hermes_cli import config as _config

_config.invalidate_env_cache()

# Mirrors resolve_provider_client's custom_entry handling at the 401 site.
custom_entry = {
"name": "longcat",
"base_url": "https://api.longcat.chat/openai",
"key_env": "LONGCAT_API_KEY",
}
custom_key = (custom_entry.get("api_key") or "").strip()
custom_key_env = (
custom_entry.get("key_env") or custom_entry.get("api_key_env") or ""
).strip()
if not custom_key and custom_key_env:
custom_key = _resolve_config_key_env(custom_key_env)
custom_key = custom_key or "no-key-required"

assert custom_key == "sk-live-key"
assert custom_key != "no-key-required"
Loading