fix: prefer ~/.hermes/.env over os.environ when seeding credential pool

When _seed_from_env() reads API keys to populate the credential pool, it
should treat ~/.hermes/.env as the authoritative source — not os.environ.
Stale env vars inherited from parent shell processes (Codex CLI, test
scripts, etc.) can shadow deliberate changes to the .env file, causing
auth.json to cache an outdated key that leads to silent 401 errors.

This is especially visible with OpenRouter: if a parent process exported
OPENROUTER_API_KEY=test-key-fresh and the user later updates .env with a
valid key, restarting Hermes still picks up the stale os.environ value,
writes it back to auth.json, and all API calls fail with 401.

Fixes #18254
This commit is contained in:
Frank Song 2026-05-01 13:42:50 +08:00 committed by Teknium
parent 10297fa23c
commit 2ef1ad280b

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import random import random
import threading import threading
import time import time
@ -13,7 +14,7 @@ from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import OPENROUTER_BASE_URL from hermes_constants import OPENROUTER_BASE_URL
from hermes_cli.config import get_env_value from hermes_cli.config import get_env_value, load_env
import hermes_cli.auth as auth_mod import hermes_cli.auth as auth_mod
from hermes_cli.auth import ( from hermes_cli.auth import (
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
@ -1380,6 +1381,16 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]: def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool, Set[str]]:
changed = False changed = False
active_sources: Set[str] = set() active_sources: Set[str] = set()
# Prefer ~/.hermes/.env over os.environ — the user's config file is the
# authoritative source for Hermes credentials. Stale env vars from parent
# processes (Codex CLI, test scripts, etc.) should not override deliberate
# changes to the .env file.
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
val = env_file.get(key) or os.environ.get(key) or ""
return val.strip()
# Honour user suppression — `hermes auth remove <provider> <N>` for an # Honour user suppression — `hermes auth remove <provider> <N>` for an
# env-seeded credential marks the env:<VAR> source as suppressed so it # env-seeded credential marks the env:<VAR> source as suppressed so it
# won't be re-seeded from the user's shell environment or ~/.hermes/.env. # won't be re-seeded from the user's shell environment or ~/.hermes/.env.
@ -1391,8 +1402,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
def _is_source_suppressed(_p, _s): # type: ignore[misc] def _is_source_suppressed(_p, _s): # type: ignore[misc]
return False return False
if provider == "openrouter": if provider == "openrouter":
# Check both os.environ and ~/.hermes/.env file # Prefer ~/.hermes/.env over os.environ
token = (get_env_value("OPENROUTER_API_KEY") or "").strip() token = _get_env_prefer_dotenv("OPENROUTER_API_KEY")
if token: if token:
source = "env:OPENROUTER_API_KEY" source = "env:OPENROUTER_API_KEY"
if _is_source_suppressed(provider, source): if _is_source_suppressed(provider, source):
@ -1418,7 +1429,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_url = "" env_url = ""
if pconfig.base_url_env_var: if pconfig.base_url_env_var:
env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/") env_url = _get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip("/")
env_vars = list(pconfig.api_key_env_vars) env_vars = list(pconfig.api_key_env_vars)
if provider == "anthropic": if provider == "anthropic":
@ -1429,8 +1440,8 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
] ]
for env_var in env_vars: for env_var in env_vars:
# Check both os.environ and ~/.hermes/.env file # Prefer ~/.hermes/.env over os.environ
token = (get_env_value(env_var) or "").strip() token = _get_env_prefer_dotenv(env_var)
if not token: if not token:
continue continue
source = f"env:{env_var}" source = f"env:{env_var}"