hermes-agent/hermes_cli/proxy/adapters/nous_portal.py
Ben Barclay 736e981abf
fix(auth): honor NOUS_INFERENCE_BASE_URL env override for Nous OAuth sessions (#52270)
The host-allowlist hardening (#30611) plus the refresh heal (#49735) left
the documented NOUS_INFERENCE_BASE_URL dev/staging escape hatch unreachable
for OAuth sessions, despite three code comments asserting it still works.

Root cause — resolution precedence in resolve_nous_runtime_credentials:

    inference_base_url = (
        _optional_base_url(state.get("inference_base_url"))  # stored — wins
        or os.getenv("NOUS_INFERENCE_BASE_URL")              # env — unreachable
        or DEFAULT_NOUS_INFERENCE_URL
    )

A staging OAuth login persists its inference_base_url, but the allowlist
rejects the staging host and the refresh heal rewrites the stored value to
the production default. The stored (now prod) value is then read BEFORE the
env var, so the override never takes effect — every request 401s against
prod or is pinned to prod, and setting the env var does nothing.

Fix: the user-set env override is the most-trusted source, so consult it
FIRST for the URL used to build the client / returned to callers — while
keeping the PERSISTED value the validated, network-provenance one (the
override is a runtime overlay, never written to auth.json, so unsetting it
cleanly reverts to prod). Applied at both chokepoints:

- resolve_nous_runtime_credentials (no-refresh read path AND refresh path)
- the nous_portal proxy adapter, which re-validates the resolver's returned
  base_url against the prod allowlist as defense-in-depth and would
  otherwise reject a legitimate staging override at the forward boundary.

New _nous_inference_env_override() / split of stored-vs-effective URL keep
the threat model intact: Portal-returned URLs are still allowlist-validated
at every network site, and the env path stays ungated (trusted OS user).

Also folds in the no-refresh read-path heal (supersedes the approach in
the open #50265): a poisoned stored staging host now heals to the prod
default on read even when no refresh fires.

Tests: TestEnvOverrideWins (env wins on read + refresh paths; override never
persisted; poisoned stored heals) and TestProxyAdapterEnvOverride. Verified
the 4 behavioral tests fail against pre-fix code and pass with the fix; full
inference-validation + nous-provider suites green (85 passed). E2E-validated
against a real temp HERMES_HOME exercising the real resolver + proxy adapter:
resolver→staging, persisted→prod, proxy→staging, unset→reverts to prod.
2026-06-25 00:11:15 -07:00

199 lines
6.9 KiB
Python

"""Nous Portal upstream adapter.
Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the
shared runtime resolver, validates or refreshes the inference JWT, then exposes
the upstream base URL plus bearer for the proxy server to forward to.
"""
from __future__ import annotations
import logging
import threading
from typing import Any, Dict, FrozenSet, Optional
from hermes_cli.auth import (
AuthError,
DEFAULT_NOUS_INFERENCE_URL,
_load_auth_store,
_auth_store_lock,
_is_terminal_nous_refresh_error,
_nous_inference_env_override,
_quarantine_nous_oauth_state,
_quarantine_nous_pool_entries,
_save_auth_store,
_validate_nous_inference_url_from_network,
_write_shared_nous_state,
resolve_nous_runtime_credentials,
)
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
logger = logging.getLogger(__name__)
# Endpoints inference-api.nousresearch.com actually serves. Anything else
# the proxy will reject with 404 — keeps stray clients from leaking weird
# requests to the upstream.
_ALLOWED_PATHS: FrozenSet[str] = frozenset(
{
"/chat/completions",
"/completions",
"/embeddings",
"/models",
}
)
class NousPortalAdapter(UpstreamAdapter):
"""Proxy upstream for the Nous Portal inference API."""
def __init__(self) -> None:
# Serialize proxy requests in this process; cross-process token refresh
# and persistence are handled by resolve_nous_runtime_credentials().
self._lock = threading.Lock()
@property
def name(self) -> str:
return "nous"
@property
def display_name(self) -> str:
return "Nous Portal"
@property
def allowed_paths(self) -> FrozenSet[str]:
return _ALLOWED_PATHS
def is_authenticated(self) -> bool:
state = self._read_state()
if state is None:
return False
# We need either a usable inference JWT OR (refresh_token + access_token)
# to recover. The refresh helper validates and refreshes as needed.
return bool(
state.get("agent_key")
or (state.get("refresh_token") and state.get("access_token"))
)
def get_credential(self) -> UpstreamCredential:
return self._get_credential()
def get_retry_credential(
self,
*,
failed_credential: UpstreamCredential,
status_code: int,
) -> Optional[UpstreamCredential]:
_ = failed_credential
if status_code != 401:
return None
logger.info("proxy: Nous upstream rejected bearer; force-refreshing invoke JWT")
return self._get_credential(
force_refresh=True,
)
def _get_credential(
self,
*,
force_refresh: bool = False,
) -> UpstreamCredential:
with self._lock:
state = self._read_state()
if state is None:
raise RuntimeError(
"Not logged into Nous Portal. Run `hermes auth add nous` first."
)
try:
refreshed = resolve_nous_runtime_credentials(
force_refresh=force_refresh,
)
except AuthError as exc:
if _is_terminal_nous_refresh_error(exc):
_quarantine_nous_oauth_state(
state,
exc,
reason="proxy_refresh_failure",
)
self._save_state(
state,
quarantine_error=exc,
quarantine_reason="proxy_refresh_failure",
)
raise RuntimeError(
f"Failed to refresh Nous Portal credentials: {exc}"
) from exc
except Exception as exc:
raise RuntimeError(
f"Failed to refresh Nous Portal credentials: {exc}"
) from exc
runtime_key = refreshed.get("api_key")
if not runtime_key:
raise RuntimeError(
"Nous Portal refresh did not return a usable inference JWT. "
"Try `hermes auth add nous` to re-authenticate."
)
# base_url returned by resolve_nous_runtime_credentials() already
# honors the NOUS_INFERENCE_BASE_URL env override (the documented
# dev/staging escape hatch). Re-validating it here against the prod
# host allowlist would wrongly reject a legitimate staging override,
# so layer the same env-first overlay on top of the network-validated
# value: env override wins, else validate the returned URL, else
# fall back to the production default (defense-in-depth for a future
# source-layer bypass).
base_url = (
_nous_inference_env_override()
or _validate_nous_inference_url_from_network(refreshed.get("base_url"))
or DEFAULT_NOUS_INFERENCE_URL
)
base_url = base_url.rstrip("/")
return UpstreamCredential(
bearer=runtime_key,
base_url=base_url,
expires_at=refreshed.get("expires_at"),
)
# ------------------------------------------------------------------
# Internal helpers — auth.json access. Kept local rather than added
# to hermes_cli.auth to avoid expanding that module's public surface.
# ------------------------------------------------------------------
def _read_state(self) -> Optional[Dict[str, Any]]:
try:
with _auth_store_lock():
store = _load_auth_store()
except Exception as exc:
logger.warning("proxy: failed to load auth store: %s", exc)
return None
providers = store.get("providers") or {}
state = providers.get("nous")
if not isinstance(state, dict):
return None
return dict(state) # copy so the refresh helper can mutate freely
def _save_state(
self,
state: Dict[str, Any],
*,
quarantine_error: Optional[AuthError] = None,
quarantine_reason: Optional[str] = None,
) -> None:
try:
with _auth_store_lock():
store = _load_auth_store()
if quarantine_error is not None and quarantine_reason:
_quarantine_nous_pool_entries(
store,
quarantine_error,
reason=quarantine_reason,
)
providers = store.setdefault("providers", {})
providers["nous"] = state
_save_auth_store(store)
_write_shared_nous_state(state)
except Exception as exc:
logger.warning("proxy: failed to persist Nous quarantine state: %s", exc)
__all__ = ["NousPortalAdapter"]