mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
The Nous Portal proxy adapter forwards minted ``agent_key`` bearer tokens to whatever ``base_url`` ``resolve_nous_runtime_credentials()`` returns, which is read directly from the refresh / agent-key-mint response and persisted to ``~/.hermes/auth.json``. With no validation beyond a trailing-slash strip, a poisoned URL (Portal-side MITM, or local write to auth.json) gets forwarded the legitimate bearer on every subsequent proxy request — exfiltrating the user's inference budget and opening a response-injection channel back into the IDE / chat client. Add ``_validate_nous_inference_url_from_network()`` in ``hermes_cli.auth``: an https + host-allowlist check that returns None for anything outside ``inference-api.nousresearch.com``, so callers fall back to the documented default rather than ship the bearer to an attacker. This commit wires the validator into the proxy adapter at ``nous_portal.py``. A follow-up commit wires it into the four refresh / mint sites in ``auth.py`` so the poisoned URL never lands in auth.json in the first place. The env-var override path (``NOUS_INFERENCE_BASE_URL``) bypasses validation by design — that's the documented staging/dev escape hatch and the env source is already trusted (the user set it themselves). Co-authored-by: memosr <mehmet.sr35@gmail.com>
195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
"""Nous Portal upstream adapter.
|
|
|
|
Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the
|
|
shared runtime resolver, refreshes the access token and resolves the
|
|
``agent_key`` compatibility credential when needed, then exposes the upstream
|
|
base URL plus bearer for the proxy server to forward to.
|
|
|
|
The ``agent_key`` field may hold either a NAS invoke JWT or the legacy
|
|
opaque session key. The refresh helper handles both — see
|
|
:func:`hermes_cli.auth.resolve_nous_runtime_credentials`.
|
|
"""
|
|
|
|
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,
|
|
NOUS_INFERENCE_AUTH_MODE_AUTO,
|
|
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
|
_load_auth_store,
|
|
_auth_store_lock,
|
|
_is_terminal_nous_refresh_error,
|
|
_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 agent_key OR (refresh_token + access_token)
|
|
# to recover. The refresh helper will mint/refresh 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(
|
|
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO,
|
|
)
|
|
|
|
def get_retry_credential(
|
|
self,
|
|
*,
|
|
failed_credential: UpstreamCredential,
|
|
status_code: int,
|
|
) -> Optional[UpstreamCredential]:
|
|
if status_code != 401:
|
|
return None
|
|
if failed_credential.bearer.count(".") != 2:
|
|
return None
|
|
logger.info("proxy: Nous upstream rejected bearer; retrying with legacy session key")
|
|
return self._get_credential(
|
|
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
|
)
|
|
|
|
def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential:
|
|
with self._lock:
|
|
state = self._read_state()
|
|
if state is None:
|
|
raise RuntimeError(
|
|
"Not logged into Nous Portal. Run `hermes login nous` first."
|
|
)
|
|
|
|
try:
|
|
refreshed = resolve_nous_runtime_credentials(
|
|
inference_auth_mode=inference_auth_mode,
|
|
)
|
|
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
|
|
|
|
agent_key = refreshed.get("api_key")
|
|
if not agent_key:
|
|
raise RuntimeError(
|
|
"Nous Portal refresh did not return a usable agent_key. "
|
|
"Try `hermes login nous` to re-authenticate."
|
|
)
|
|
|
|
base_url = (
|
|
_validate_nous_inference_url_from_network(refreshed.get("base_url"))
|
|
or DEFAULT_NOUS_INFERENCE_URL
|
|
)
|
|
base_url = base_url.rstrip("/")
|
|
|
|
return UpstreamCredential(
|
|
bearer=agent_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"]
|