Files
hermes-agent/hermes_cli/auth_nous.py
kshitijk4poor e87f673faa fix(codex): send catalog/image credentials only to their own route
Follow-up to the two contributor commits for #121486. The picker, the
image plugin and the auxiliary Codex client still composed a pooled
gateway key with a base re-read from ambient state (HERMES_CODEX_BASE_URL
or the chatgpt.com default), so a model.base_url-only gateway (env unset)
still sent its key to chatgpt.com.

- auth_codex: resolve_codex_runtime_credentials reports the host a pooled
  credential actually routes to (runtime_provider._pool_entry_mode_and_url:
  env > model.base_url while the row is canonical > row URL) instead of the
  ambient default; get_codex_auth_status carries the same bound base_url.
- picker: get_codex_model_ids(access_token, base_url=) now receives the base
  resolved with the token from hermes_cli/models.py, the CLI default-model
  swap (self.base_url) and the `hermes model` Codex flow.
- aux/image: _resolve_codex_credential_and_base() returns (token, base) from
  one pool selection; the image plugin, _build_codex_client and the raw
  Codex client use it (profile-scoped override from #121497 still wins).
- model_metadata: the non-JWT refusal now applies only when the target is
  chatgpt.com; a gateway key may probe its own gateway's /models.

Adversarial regressions: model.base_url with env unset, env/route mismatch,
opaque + JWT gateway keys, pool-selected credential, pool row with its own
gateway URL, direct-ChatGPT positive control.

Addresses @andrexibiza's review on #121508.
2026-09-25 21:27:06 +05:30

1648 lines
83 KiB
Python

"""Nous Portal OAuth: device-code login, refresh, shared-store mirroring, JWT selection, status.
Split out of ``hermes_cli/auth.py``; origin helpers are imported lazily inside each function
so ``hermes_cli.auth.<name>`` patches still intercept (and no import cycle).
"""
from __future__ import annotations
import logging
import hashlib
import json
import os
import threading
import time
import uuid
from contextlib import contextmanager, suppress
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, FrozenSet, List, Optional
from urllib.parse import urlparse
from hermes_cli.auth_codex import _pool_entries
from hermes_cli.auth_constants import (
_decode_jwt_claims, AUTH_LOCK_TIMEOUT_SECONDS, AuthError, DEFAULT_NOUS_CLIENT_ID,
DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_SCOPE, DEFAULT_NOUS_WELCOME_URL,
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS, NOUS_AUTH_PATH_INVOKE_JWT, NOUS_BILLING_MANAGE_SCOPE,
NOUS_DEVICE_CODE_SOURCE, NOUS_INFERENCE_INVOKE_SCOPE, NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
_nous_err, httpx)
if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
from hermes_cli.auth import ProviderConfig
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
logger = logging.getLogger("hermes_cli.auth")
_UNUSABLE_JWT_RELOGIN = "Re-authenticate with: hermes auth add nous"
def _unusable_invoke_jwt_error(reason: str, *, no_refresh_token: bool = False) -> AuthError:
"""Shared ``relogin=True`` error for an access token that is not a usable inference JWT.
With no refresh token the failure is a state-shape one (nothing to redeem), so it carries the
terminal ``nous_auth_missing_refresh_token`` code the pool recognises instead of the JWT
``reason``, which would bench the row as a transient outage (#113718).
"""
detail = " and no refresh token is available" if no_refresh_token else ""
return _nous_err(
f"Nous Portal access token is not a usable inference JWT ({reason}){detail}. "
f"{_UNUSABLE_JWT_RELOGIN}",
"nous_auth_missing_refresh_token" if no_refresh_token else reason, relogin=True)
def _token_fingerprint(token: Any) -> Optional[str]:
"""Return a short hash fingerprint for telemetry without leaking token bytes."""
cleaned = token.strip() if isinstance(token, str) else ""
return hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:12] if cleaned else None
def _oauth_trace(event: str, *, sequence_id: Optional[str] = None, **fields: Any) -> None:
if os.getenv("HERMES_OAUTH_TRACE", "").strip().lower() not in {"1", "true", "yes", "on"}:
return
payload: Dict[str, Any] = {"event": event}
if sequence_id:
payload["sequence_id"] = sequence_id
payload.update(fields)
logger.info("oauth_trace %s", json.dumps(payload, sort_keys=True, ensure_ascii=False))
def _iso_after(now: datetime, ttl_seconds: int) -> str:
"""ISO timestamp *ttl_seconds* after *now* (UTC)."""
return datetime.fromtimestamp(now.timestamp() + ttl_seconds, tz=timezone.utc).isoformat()
# Nous agent-key slots; a fresh login persists them as None, quarantine strips them.
_NOUS_EMPTY_AGENT_KEY_FIELDS: Dict[str, Any] = {
"agent_key": None, "agent_key_id": None, "agent_key_expires_at": None,
"agent_key_expires_in": None, "agent_key_reused": None, "agent_key_obtained_at": None}
_NOUS_STALE_PORTAL_HOSTS: FrozenSet[str] = frozenset({"api.nousresearch.com"})
def _portal_entitlement_message(capability: str) -> str:
"""Portal entitlement notice for *capability* (fresh account data), "" when unavailable."""
from hermes_cli.nous_account import (
format_nous_portal_entitlement_message, get_nous_portal_account_info)
account_info = get_nous_portal_account_info(force_fresh=True)
return format_nous_portal_entitlement_message(account_info, capability=capability) or ""
def _format_nous_entitlement_auth_error(error: AuthError) -> str:
with suppress(Exception):
if message := _portal_entitlement_message("Nous model access"):
return message
return f"{error} Check credits or billing in Nous Portal, then retry."
def _migrate_stale_nous_portal_url(providers: Dict[str, Any]) -> None:
nous = providers.get("nous")
if not isinstance(nous, dict):
return
stored = (nous.get("portal_base_url") or "").strip()
if stored and urlparse(stored).hostname in _NOUS_STALE_PORTAL_HOSTS:
logger.warning(
"auth: migrating stale nous portal_base_url %s -> %s", stored, DEFAULT_NOUS_PORTAL_URL)
nous["portal_base_url"] = DEFAULT_NOUS_PORTAL_URL
# Allowlist of hosts the Nous Portal proxy will forward inference JWTs to — a bearer sent anywhere
# else would leak. Consulted only for URLs from the NETWORK side (Portal refresh responses);
# the NOUS_INFERENCE_BASE_URL env override bypasses it (documented dev/staging escape hatch, the
# user set it themselves).
_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({
"inference-api.nousresearch.com",
# Free-tier (anonymous) host: serves the single ``nous/welcome`` model.
"welcome-api.nousresearch.com"})
def _nous_inference_host_allowed(hostname: Optional[str]) -> bool:
"""Production hosts always; otherwise only the host the operator named in
``NOUS_INFERENCE_BASE_URL``.
A non-production Portal's refresh response names that environment's inference gateway. The
Portal-returned value is network provenance, so it does not get bearer-receive authority on
its own — not even for a Nous-owned host: the operator's explicit override is the authority,
and the network value is accepted exactly when it agrees with it. Then the persisted endpoint,
the pricing scope and the proxy all follow the environment the operator chose, and the
per-turn "refusing inference URL host" warning stops.
"""
if hostname in _ALLOWED_NOUS_INFERENCE_HOSTS:
return True
if not hostname:
return False
override = _nous_inference_env_override()
return override is not None and urlparse(override).hostname == hostname
def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]:
"""Validate a Portal-returned inference URL against the host allowlist.
Defense-in-depth: a compromised refresh response (MITM, response injection) could otherwise
redirect every proxy request — bearing the user's inference JWT — to an attacker endpoint.
"""
cleaned = url.strip() if isinstance(url, str) else ""
if not cleaned:
return None
try:
parsed = urlparse(cleaned)
except Exception:
return None
if parsed.scheme != "https":
logger.warning(
"nous: refusing non-https inference URL scheme %r from Portal response", parsed.scheme)
return None
if not _nous_inference_host_allowed(parsed.hostname):
logger.warning(
"nous: refusing inference URL host %r from Portal response "
"(not in allowlist); falling back to default",
parsed.hostname)
return None
return cleaned.rstrip("/")
def _scoped_operator_override(*names: str) -> Optional[str]:
"""The first set operator routing override among ``names`` (``NOUS_INFERENCE_BASE_URL``,
``HERMES_PORTAL_BASE_URL`` / its ``NOUS_PORTAL_BASE_URL`` alias), resolved through the profile
secret scope, or None.
``get_secret`` already reads ``os.environ`` for a single-profile process, so the only time it
raises is a multi-profile call that has lost its profile scope. That call has no authority to
route on the launch profile's value — returning the ambient env there would send a secondary's
tokens to the launch profile's Portal or inference host — so the override is simply absent.
Absent is not free: default routing then applies, so a non-production deployment's token is
spent against the production hosts. One WARNING per lost-scope event names the override; the
downstream "ignoring invalid portal_base_url" line never says which caller lost its scope.
"""
from agent.secret_scope import UnscopedSecretError, get_secret
try:
for name in names:
value = get_secret(name)
if value:
return value
return None
except UnscopedSecretError:
logger.warning(
"nous: %s unreadable — no profile secret scope on a multiplexed call; treating the "
"override as absent (default routing applies). The caller needs a profile scope binding.",
"/".join(names))
return None
def _nous_inference_env_override() -> Optional[str]:
"""User-set ``NOUS_INFERENCE_BASE_URL`` override (trailing slash stripped) or None.
Documented dev/staging escape hatch; the env source is trusted, so unlike Portal-returned URLs
it is intentionally NOT gated by the network host allowlist. Read through the profile-aware
resolver so a multiplexed profile uses its own override and never inherits the default
profile's process-wide value (#65941).
"""
from hermes_cli.auth import _optional_base_url
return _optional_base_url(_scoped_operator_override("NOUS_INFERENCE_BASE_URL"))
def _nous_portal_env_override() -> Optional[str]:
"""``HERMES_PORTAL_BASE_URL`` / ``NOUS_PORTAL_BASE_URL`` override or None.
Documented dev/staging escape hatch (e.g. hosted agents on the staging Portal). Trusted env
source: must NOT be gated by ``_NOUS_PORTAL_ALLOWED_HOSTS``, which rejects untrusted
NETWORK-provided values persisted to auth.json, not operator config. Read through the
profile secret scope like ``_nous_inference_env_override``: it is reached on every routed
turn (``_nous_effective_routing``), and a raw environ read would POST a multiplexed
secondary's refresh token to the DEFAULT profile's Portal.
"""
from hermes_cli.auth import _optional_base_url
return _optional_base_url(_scoped_operator_override("HERMES_PORTAL_BASE_URL", "NOUS_PORTAL_BASE_URL"))
def _scope_values(raw_scope: Any) -> set[str]:
# OAuth token responses return a space-separated string; collections are kept for JWT ``scp``
# claims and older stored fixtures.
scopes: set[str] = set()
if isinstance(raw_scope, str):
scopes.update(part for part in raw_scope.replace(",", " ").split() if part.strip())
elif isinstance(raw_scope, (list, tuple, set, frozenset)):
scopes.update(*(_scope_values(item) for item in raw_scope if isinstance(item, str)))
return scopes
def _nous_invoke_jwt_status(
token: Any, *, scope: Any = None, expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS) -> Optional[str]:
"""Return None when the token can be used for inference, else a reason."""
from hermes_cli.auth import _is_expiring
claims = _decode_jwt_claims(token)
if not claims:
return "access_token_not_jwt"
scopes = (_scope_values(scope) | _scope_values(claims.get("scope"))
| _scope_values(claims.get("scp")))
if NOUS_INFERENCE_INVOKE_SCOPE not in scopes:
return "missing_inference_invoke_scope"
exp = claims.get("exp")
skew = max(0, int(min_ttl_seconds))
if isinstance(exp, (int, float)):
return "invoke_jwt_expiring" if float(exp) <= (time.time() + skew) else None
return "invoke_jwt_expiry_unknown_or_expiring" if _is_expiring(expires_at, skew) else None
def _nous_invoke_jwt_is_usable(
token: Any, *, scope: Any = None, expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS) -> bool:
from hermes_cli.auth import _nous_invoke_jwt_status
return _nous_invoke_jwt_status(
token, scope=scope, expires_at=expires_at, min_ttl_seconds=min_ttl_seconds) is None
def _state_invoke_jwt_status(state: Dict[str, Any], token: Any) -> Optional[str]:
"""``_nous_invoke_jwt_status`` for *token* using *state*'s scope / expires_at (patchable)."""
from hermes_cli.auth import _nous_invoke_jwt_status
return _nous_invoke_jwt_status(
token, scope=state.get("scope"), expires_at=state.get("expires_at"))
def _assert_nous_inference_jwt_usable(state: Dict[str, Any], *, access_token: Any = None) -> None:
token = state.get("access_token") if access_token is None else access_token
reason = _state_invoke_jwt_status(state, token)
if reason is not None:
raise _unusable_invoke_jwt_error(reason)
def _remaining_ttl(expires_at: Any, fallback_expires_in: Any) -> int:
"""Seconds until *expires_at* (ISO), else *fallback_expires_in* coerced to a TTL."""
from hermes_cli.auth import _coerce_ttl_seconds, _parse_iso_timestamp
expires_epoch = _parse_iso_timestamp(expires_at)
if expires_epoch is not None:
return max(0, int(expires_epoch - time.time()))
return _coerce_ttl_seconds(fallback_expires_in)
def _nous_jwt_expires_at(token: Any, fallback_expires_at: Any = None) -> Optional[str]:
claims = _decode_jwt_claims(token)
exp = claims.get("exp")
if isinstance(exp, (int, float)):
with suppress(Exception):
return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat()
return fallback_expires_at if isinstance(fallback_expires_at, str) else None
def _set_nous_agent_key_from_invoke_jwt(
state: Dict[str, Any], *, obtained_at: Optional[str] = None) -> None:
from hermes_cli.auth import _nonempty_str
access_token = state.get("access_token")
if not _nonempty_str(access_token):
return
existing_obtained_at = state.get("agent_key_obtained_at")
if not obtained_at:
reuse = state.get("agent_key") == access_token and _nonempty_str(existing_obtained_at)
obtained_at = existing_obtained_at if reuse else datetime.now(timezone.utc).isoformat()
expires_at = _nous_jwt_expires_at(access_token, state.get("expires_at"))
expires_in = _remaining_ttl(expires_at, state.get("expires_in"))
if expires_at:
state["expires_at"] = expires_at
state["expires_in"] = expires_in
state.update(
agent_key=access_token, agent_key_id=None, agent_key_expires_at=expires_at,
agent_key_expires_in=expires_in, agent_key_reused=False, agent_key_obtained_at=obtained_at)
def _select_nous_invoke_jwt(
state: Dict[str, Any], *, access_token: Any = None, sequence_id: Optional[str] = None) -> None:
from hermes_cli.auth import _nonempty_str
if _nonempty_str(access_token):
state["access_token"] = access_token
_set_nous_agent_key_from_invoke_jwt(state)
logger.debug("Nous inference auth: using NAS invoke JWT")
_oauth_trace(
"nous_invoke_jwt_selected", sequence_id=sequence_id,
access_token_fp=_token_fingerprint(state.get("access_token")))
# Derived from expires_at/JWT exp and tick down between reads; persisting only these changes makes
# auth.json noisy and defeats the mtime-keyed auth-status cache.
_NOUS_EFFECTIVE_STATE_IGNORED_KEYS = frozenset({"expires_in", "agent_key_expires_in"})
def _nous_effective_provider_state(state: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in state.items() if k not in _NOUS_EFFECTIVE_STATE_IGNORED_KEYS}
NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
_nous_shared_lock_holder = threading.local()
def _nous_shared_auth_dir() -> Path:
"""Directory of the shared Nous token store: ``HERMES_SHARED_AUTH_DIR`` or ``<root>/shared/``.
Outside any named profile so all profiles share it (``hermes --profile X auth add nous --type
oauth`` one-tap imports it). Written on login AND every runtime refresh so the refresh_token
stays current across profiles; a stale token just falls back to device-code.
"""
override = os.getenv("HERMES_SHARED_AUTH_DIR", "").strip()
if override:
return Path(override).expanduser()
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "shared"
def _nous_shared_store_path() -> Path:
path = _nous_shared_auth_dir() / NOUS_SHARED_STORE_FILENAME
# Seat belt (mirrors the _auth_file_path() guard): under pytest, refuse a path under the real
# user's Hermes root so a test that forgot HERMES_SHARED_AUTH_DIR fails loudly instead of
# corrupting cross-profile state.
if os.environ.get("PYTEST_CURRENT_TEST"):
from hermes_constants import get_default_hermes_root
real_home_shared = (
get_default_hermes_root() / "shared" / NOUS_SHARED_STORE_FILENAME).resolve(strict=False)
try:
resolved = path.resolve(strict=False)
except Exception:
resolved = path
if resolved == real_home_shared:
raise RuntimeError(
f"Refusing to touch real user shared Nous auth store during test run: "
f"{path}. Set HERMES_SHARED_AUTH_DIR to a tmp_path in your test fixture.")
return path
@contextmanager
def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
"""Cross-profile lock for the shared Nous OAuth store.
Lock ordering invariant: if both this and ``_auth_store_lock`` need to be held, acquire
``_auth_store_lock`` FIRST. All runtime refresh paths follow this order.
"""
from hermes_cli.auth import _file_lock
try:
lock_path = _nous_shared_store_path().with_suffix(".lock")
except RuntimeError:
yield # No HERMES_HOME yet (pre-setup): fall through without locking.
return
with _file_lock(
lock_path, _nous_shared_lock_holder, timeout_seconds,
"Timed out waiting for shared Nous auth lock"):
yield
def _shared_lock_timeout(timeout_seconds: float) -> float:
return max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)
# OAuth fields mirrored between a profile's Nous state and the shared cross-profile store.
_NOUS_SHARED_STATE_KEYS = (
"access_token", "refresh_token", "token_type", "scope", "client_id", "portal_base_url",
"inference_base_url", "obtained_at", "expires_at",
# Guest (``auth_method: anonymous``) identity: the ``anon_`` credential is the refresh material.
"auth_method", "account_tier", "anon_token", "user_id", "org_id")
def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool:
"""Copy fresher shared OAuth tokens into a profile-local Nous state."""
from hermes_cli.auth import _nonempty_str, _parse_iso_timestamp, _read_shared_nous_state
shared = _read_shared_nous_state() or {}
shared_refresh = shared.get("refresh_token")
if not _nonempty_str(shared_refresh):
return False # a free-tier identity has no refresh token; nothing to merge into an OAuth state
shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0
local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0
refresh_changed = shared_refresh.strip() != str(state.get("refresh_token") or "").strip()
if not refresh_changed and not shared_access_exp > local_access_exp:
return False
for key in _NOUS_SHARED_STATE_KEYS:
value = shared.get(key)
if value not in {None, ""}:
state[key] = value
return True
def _nous_shared_shape(src: Dict[str, Any]) -> Dict[str, Any]:
"""The defaulted OAuth core (tokens + routing + expiry) shared across profiles."""
return {
"access_token": src.get("access_token"), "refresh_token": src.get("refresh_token"),
"token_type": src.get("token_type") or "Bearer",
"scope": src.get("scope") or DEFAULT_NOUS_SCOPE,
"client_id": src.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": src.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
# A guest's route defaults to the welcome host: the paid host cross-refuses its JWT.
"inference_base_url": src.get("inference_base_url") or (
DEFAULT_NOUS_WELCOME_URL if src.get("auth_method") == "anonymous" else DEFAULT_NOUS_INFERENCE_URL),
"obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at"),
**{k: src[k] for k in ("auth_method", "account_tier", "anon_token", "user_id", "org_id")
if src.get(k) not in (None, "")}}
def _write_shared_nous_state(state: Dict[str, Any]) -> None:
"""Persist a minimal copy of the Nous OAuth state to the shared store.
Best-effort: failures are logged and swallowed; per-profile auth.json stays the source of truth.
"""
from hermes_cli.auth import _nonempty_str, _save_private_json
refresh_token = state.get("refresh_token")
# Nothing worth sharing without refresh material: an OAuth refresh_token (with its access token),
# or a guest's anon_ credential, which is the whole identity and may not have been exchanged yet.
is_guest = _nonempty_str(state.get("anon_token"))
if not is_guest and not (_nonempty_str(refresh_token) and _nonempty_str(state.get("access_token"))):
return
shared = {
"_schema": 1, **_nous_shared_shape(state),
"updated_at": datetime.now(timezone.utc).isoformat()}
try:
with _nous_shared_store_lock():
path = _nous_shared_store_path()
_save_private_json(path, shared, sort_keys=True)
_oauth_trace(
"nous_shared_store_written", path=str(path),
refresh_token_fp=_token_fingerprint(refresh_token))
except Exception as exc:
logger.debug("Failed to write shared Nous auth store: %s", exc)
def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
"""Shared Nous OAuth state when present and well-formed, else None.
None (missing / unreadable / malformed / lacking tokens) means "fall through to device-code".
"""
from hermes_cli.auth import _nonempty_str
try:
path = _nous_shared_store_path()
except RuntimeError:
return None # Test seat belt tripped — treat as missing
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as exc:
logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
return None
if not isinstance(payload, dict):
return None
has_tokens = _nonempty_str(payload.get("anon_token")) or (
_nonempty_str(payload.get("access_token")) and _nonempty_str(payload.get("refresh_token")))
return payload if has_tokens else None
def _clear_shared_nous_state(reason: str) -> None:
"""Remove the shared Nous OAuth store after a terminal token failure."""
try:
with _nous_shared_store_lock():
_nous_shared_store_path().unlink(missing_ok=True)
_oauth_trace("nous_shared_store_cleared", reason=reason)
except Exception as exc:
logger.debug("Failed to clear shared Nous auth store: %s", exc)
def _quarantine_forensics(state: Dict[str, Any], error: AuthError, reason: str) -> Dict[str, Any]:
"""Redaction-safe forensic record for a quarantine: fingerprints, sizes and booleans only.
NEVER include a raw token/agent_key (credential-shaped literals get corrupted in logs). The
12-char SHA-256 prefix correlates to NAS's refreshTokenHash without leaking the secret;
provenance is client_id + agent_key_id (Nous state has no session_id).
"""
from hermes_cli.auth import _auth_file_path
forensic: Dict[str, Any] = {
"reason": reason, "error_code": error.code, "client_id": state.get("client_id"),
"agent_key_id": state.get("agent_key_id"),
"refresh_token_fp": _token_fingerprint(state.get("refresh_token"))}
# On-disk integrity of the auth store at the moment of quarantine.
try:
auth_path = _auth_file_path()
forensic["auth_json_path"] = str(auth_path)
try:
st = os.stat(auth_path)
forensic.update(
auth_json_size=st.st_size, auth_json_mtime=st.st_mtime, auth_json_exists=True)
except FileNotFoundError:
forensic["auth_json_exists"] = False
except Exception as exc: # pragma: no cover - never let logging break quarantine
forensic["auth_json_stat_error"] = repr(exc)
# Was the token already past its own expiry when it was rejected?
already_expired: Optional[bool] = None
expires_at_raw = state.get("expires_at")
if isinstance(expires_at_raw, str) and expires_at_raw:
try:
parsed = datetime.fromisoformat(expires_at_raw)
already_expired = (parsed.replace(tzinfo=parsed.tzinfo or timezone.utc)
< datetime.now(timezone.utc))
except ValueError:
already_expired = None
forensic["token_already_expired"] = already_expired
return forensic
def _quarantine_nous_oauth_state(state: Dict[str, Any], error: AuthError, *, reason: str) -> None:
"""Keep routing metadata but remove dead OAuth material so it is not replayed.
Only for terminal errors (``*_refresh_failed`` = HTTP 400/401/403 invalid_grant / revoked /
refresh_token_reused; ``*_auth_missing_refresh_token``), all ``relogin_required=True`` —
transient 429/5xx never quarantine.
"""
from hermes_cli.auth import (
_FLAT_OAUTH_TOKEN_KEYS, _last_auth_error_marker, invalidate_nous_auth_status_cache)
# Forensics BEFORE clearing token material: a hosted agent quarantined here is otherwise only
# visible as a later "No access token found" WARNING, too late to root-cause. Managed log
# drains may be WARNING-only, so this MUST be logger.warning.
logger.warning(
"Nous OAuth state quarantined (terminal auth death): %s",
json.dumps(_quarantine_forensics(state, error, reason), sort_keys=True, ensure_ascii=False))
for key in (*_FLAT_OAUTH_TOKEN_KEYS, *_NOUS_EMPTY_AGENT_KEY_FIELDS):
state.pop(key, None)
state["last_auth_error"] = _last_auth_error_marker("nous", error, reason=reason)
_clear_shared_nous_state(reason)
invalidate_nous_auth_status_cache()
def _quarantine_nous_pool_entries(
auth_store: Dict[str, Any], error: AuthError, *, reason: str) -> bool:
"""Remove singleton-seeded Nous pool entries that contain dead OAuth state."""
entries = _pool_entries(auth_store, "nous")
if entries is None:
return False
singleton_sources = {NOUS_DEVICE_CODE_SOURCE, f"manual:{NOUS_DEVICE_CODE_SOURCE}"}
retained = [
e for e in entries if not (isinstance(e, dict) and e.get("source") in singleton_sources)]
removed = len(retained) != len(entries)
if removed:
auth_store["credential_pool"]["nous"] = retained
_oauth_trace("nous_pool_device_code_quarantined", reason=reason, error_code=error.code)
return removed
def _try_import_shared_nous_state(*, timeout_seconds: float = 15.0) -> Optional[Dict[str, Any]]:
"""Rehydrate Nous OAuth state from the shared store via a forced refresh.
Returns auth_state ready for ``persist_nous_credentials()``; None on any failure (expired
token, portal unreachable) so the caller falls through to device-code.
"""
from hermes_cli.auth import (
_read_shared_nous_state, _write_shared_nous_state, refresh_nous_oauth_from_state,
_is_terminal_nous_refresh_error)
try:
with _nous_shared_store_lock(timeout_seconds=_shared_lock_timeout(timeout_seconds)):
shared = _read_shared_nous_state()
if not shared:
return None
# Full state dict so refresh_nous_oauth_from_state has every field it needs.
state: Dict[str, Any] = {
**_nous_shared_shape(shared), "agent_key": None, "agent_key_expires_at": None,
"tls": {"insecure": False, "ca_bundle": None}}
refreshed = refresh_nous_oauth_from_state(
state, timeout_seconds=timeout_seconds, force_refresh=True,
on_state_update=lambda updated, _reason: _write_shared_nous_state(updated))
_write_shared_nous_state(refreshed)
except Exception as exc:
is_auth = isinstance(exc, AuthError)
_oauth_trace(
"nous_shared_import_failed", error_type=type(exc).__name__,
**({"error_code": getattr(exc, "code", None)} if is_auth else {}))
if is_auth and _is_terminal_nous_refresh_error(exc):
_clear_shared_nous_state("shared_import_terminal_refresh_failure")
logger.debug("Shared Nous import failed: %s", exc)
return None
return refreshed
def _refresh_access_token(
*, client: httpx.Client, portal_base_url: str, client_id: str, refresh_token: str,
) -> Dict[str, Any]:
response = client.post(
f"{portal_base_url}/api/oauth/token",
headers={"x-nous-refresh-token": refresh_token},
data={"grant_type": "refresh_token", "client_id": client_id})
if response.status_code == 200:
payload = response.json()
if "access_token" not in payload:
raise _nous_err("Refresh response missing access_token", "invalid_token", relogin=True)
return payload
if 500 <= response.status_code <= 599:
raise AuthError(
f"Nous Portal is temporarily unavailable (HTTP {response.status_code}).",
provider="nous", code="temporarily_unavailable", retryable=True)
# Vercel's Security Checkpoint in front of the Portal answers non-browser clients with a
# 403 (``x-vercel-mitigated: deny``) or 429 (``challenge``) page (#120602). That is the edge
# refusing the request, not the token endpoint rejecting the grant, so keep the credentials
# instead of forcing a re-login.
mitigated = response.headers.get("x-vercel-mitigated") if response.status_code in {403, 429} else None
if mitigated:
from agent.retry_utils import parse_retry_after_seconds
raise AuthError(
f"Nous Portal's edge firewall challenged the token refresh (HTTP {response.status_code}, "
f"x-vercel-mitigated={mitigated}). Credentials kept; try again shortly.",
provider="nous", code="upstream_blocked", retryable=True,
retry_after=parse_retry_after_seconds(response.headers))
from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES
try:
error_payload = response.json()
except Exception:
error_payload = {}
if not isinstance(error_payload, dict):
error_payload = {}
# Only an explicit OAuth grant-dead code is terminal: a 429/404 gateway body without an
# ``error`` key says nothing about the refresh token, so it must not wipe credentials.
# A 401/403 without an ``error`` code still means the token endpoint rejected the refresh
# token, so it is reported as ``invalid_grant`` (terminal) rather than left unclassified.
raw_code = error_payload.get("error")
if raw_code is None and response.status_code in {401, 403}:
raw_code = "invalid_grant"
code = None if raw_code is None else str(raw_code)
description = str(error_payload.get("error_description") or "Refresh token exchange failed")
relogin = code in _OAUTH_GRANT_DEAD_CODES
# OAuth 2.1 "refresh token reuse": an external process (health check, monitoring tool, custom
# self-heal hook) redeemed Hermes's refresh_token without persisting the rotated token, so the
# server retired the original and revoked the whole session chain as a token-theft signal.
if code == "refresh_token_reused" or "reuse" in description.lower():
description = (
"Nous Portal detected refresh-token reuse and revoked this session.\n"
"This usually means an external process (monitoring script, "
"custom self-heal hook, or another Hermes install sharing "
"~/.hermes/auth.json) called POST /api/oauth/token with Hermes's "
"refresh token without persisting the rotated token back.\n"
"Nous refresh tokens are single-use — only Hermes may call the "
"refresh endpoint. For health checks, use `hermes auth status` "
"instead.\n"
"Re-authenticate with: hermes auth add nous")
relogin = True
raise _nous_err(description, code, relogin=relogin)
def _refresh_nous_or_quarantine(
*, client: httpx.Client, auth_store: Dict[str, Any], state: Dict[str, Any],
portal_base_url: str, client_id: str, refresh_token: str, reason: str,
persist: Callable[[], None]) -> Dict[str, Any]:
"""Redeem the refresh token; on terminal failure quarantine state + pool, persist, re-raise."""
from hermes_cli.auth import _refresh_access_token, _is_terminal_nous_refresh_error
try:
return _refresh_access_token(
client=client, portal_base_url=portal_base_url, client_id=client_id,
refresh_token=refresh_token)
except AuthError as exc:
if _is_terminal_nous_refresh_error(exc):
_quarantine_nous_oauth_state(state, exc, reason=reason)
_quarantine_nous_pool_entries(auth_store, exc, reason=reason)
persist()
raise
def _apply_nous_refreshed_tokens(
state: Dict[str, Any], refreshed: Dict[str, Any], refresh_token: str, *,
inference_base_url: Optional[str] = None) -> None:
"""Write a successful Nous token-refresh payload into *state* (tokens + expiry fields).
*inference_base_url*, when given, is the healed network-provenance URL to persist alongside
the rotated tokens (key order in auth.json is preserved from the original login shape).
"""
from hermes_cli.auth import _coerce_ttl_seconds
now = datetime.now(timezone.utc)
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
state["access_token"] = refreshed["access_token"]
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
state["scope"] = refreshed.get("scope") or state.get("scope")
if inference_base_url is not None:
state["inference_base_url"] = inference_base_url
state["obtained_at"] = now.isoformat()
state["expires_in"] = access_ttl
state["expires_at"] = _iso_after(now, access_ttl)
def _healed_nous_inference_url(refreshed: Dict[str, Any]) -> str:
"""Validated network-provenance inference URL from a refresh payload, healed to the default.
A Portal URL rejected by the allowlist resets to the production default instead of leaving a
previously-persisted bad host (e.g. a stale staging URL) in place — otherwise a poisoned
auth.json re-validates to None on every refresh and silently re-uses the dead endpoint.
"""
url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
return url or DEFAULT_NOUS_INFERENCE_URL
def _nous_http_client(timeout_seconds: float, verify: Any) -> httpx.Client:
return httpx.Client(
timeout=httpx.Timeout(timeout_seconds), headers={"Accept": "application/json"},
verify=verify)
def _model_priority(mid: str) -> tuple:
"""Sort key: opus > pro > haiku/flash > sonnet (sonnet is cheap/fast; best model first)."""
low = mid.lower()
rank = (0 if "opus" in low else 1 if "pro" in low and "sonnet" not in low
else 3 if "sonnet" in low else 2)
return (rank, mid)
def fetch_nous_models(
*, inference_base_url: str, api_key: str, timeout_seconds: float = 15.0,
verify: bool | str = True) -> List[str]:
"""Fetch available model IDs from the Nous inference API."""
from hermes_cli.auth import _nonempty_str
with _nous_http_client(timeout_seconds, verify) as client:
response = client.get(
f"{inference_base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"})
if response.status_code != 200:
description = f"/models request failed with status {response.status_code}"
try:
err = response.json()
description = str(err.get("error_description") or err.get("error") or description)
except Exception as e:
logger.debug("Could not parse error response JSON: %s", e)
raise _nous_err(description, "models_fetch_failed")
data = response.json().get("data")
if not isinstance(data, list):
return []
model_ids: List[str] = []
for item in data:
model_id = item.get("id") if isinstance(item, dict) else None
# Hermes models aren't reliable for agentic tool-calling
if _nonempty_str(model_id) and "hermes" not in model_id.lower():
model_ids.append(model_id.strip())
model_ids.sort(key=_model_priority)
return list(dict.fromkeys(model_ids))
def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
from hermes_cli.auth import _nonempty_str
key = state.get("agent_key")
return _nonempty_str(key) and _nous_invoke_jwt_is_usable(
key, scope=state.get("scope"), expires_at=state.get("agent_key_expires_at"),
min_ttl_seconds=max(0, int(min_ttl_seconds)))
def refresh_nous_oauth_pure(
access_token: str, refresh_token: str, client_id: str, portal_base_url: str,
inference_base_url: str, *, token_type: str = "Bearer", scope: str = DEFAULT_NOUS_SCOPE,
obtained_at: Optional[str] = None, expires_at: Optional[str] = None,
agent_key: Optional[str] = None, agent_key_expires_at: Optional[str] = None,
timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None,
force_refresh: bool = False,
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None) -> Dict[str, Any]:
"""Refresh Nous OAuth state without mutating auth.json directly.
``on_state_update`` fires after a successful access-token refresh so callers owning persistent
state can save the rotated refresh token before later validation can fail.
"""
return refresh_nous_oauth_from_state(
{
"access_token": access_token, "refresh_token": refresh_token, "client_id": client_id,
"portal_base_url": portal_base_url, "inference_base_url": inference_base_url,
"token_type": token_type, "scope": scope, "obtained_at": obtained_at,
"expires_at": expires_at, "agent_key": agent_key,
"agent_key_expires_at": agent_key_expires_at,
"tls": {"insecure": insecure, "ca_bundle": ca_bundle}},
timeout_seconds=timeout_seconds, force_refresh=force_refresh,
on_state_update=on_state_update)
def refresh_nous_oauth_from_state(
src: Dict[str, Any], *, timeout_seconds: float = 15.0, force_refresh: bool = False,
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None) -> Dict[str, Any]:
"""Refresh Nous OAuth from a state dict (defaults filled in) without mutating auth.json."""
from hermes_cli.auth import (
_assert_nous_inference_jwt_usable, _refresh_access_token, _resolve_verify,
_select_nous_invoke_jwt)
tls = src.get("tls") or {}
insecure, ca_bundle = tls.get("insecure"), tls.get("ca_bundle")
state: Dict[str, Any] = {
"access_token": src.get("access_token", ""), "refresh_token": src.get("refresh_token", ""),
"client_id": src.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": (src.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL).rstrip("/"),
"inference_base_url": (
src.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL).rstrip("/"),
"token_type": src.get("token_type") or "Bearer",
"scope": src.get("scope") or DEFAULT_NOUS_SCOPE,
"obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at"),
"agent_key": src.get("agent_key"), "agent_key_expires_at": src.get("agent_key_expires_at"),
"tls": {"insecure": bool(insecure), "ca_bundle": ca_bundle}}
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
with _nous_http_client(timeout_seconds or 15.0, verify) as client:
current_invoke_jwt_status = _state_invoke_jwt_status(state, state.get("access_token"))
if force_refresh or current_invoke_jwt_status is not None:
refresh_token_value = state.get("refresh_token")
if not isinstance(refresh_token_value, str) or not refresh_token_value:
if current_invoke_jwt_status is not None:
raise _unusable_invoke_jwt_error(
current_invoke_jwt_status, no_refresh_token=True)
raise _nous_err(
"No refresh token is available for Nous Portal.", "nous_auth_missing_refresh_token",
relogin=True)
refreshed = _refresh_access_token(
client=client, portal_base_url=state["portal_base_url"],
client_id=state["client_id"], refresh_token=refresh_token_value)
_apply_nous_refreshed_tokens(
state, refreshed, refresh_token_value,
inference_base_url=_healed_nous_inference_url(refreshed))
if on_state_update is not None:
on_state_update(dict(state), "post_refresh_access_token")
_assert_nous_inference_jwt_usable(state)
_select_nous_invoke_jwt(state)
return state
def persist_nous_credentials(creds: Dict[str, Any], *, label: Optional[str] = None):
"""Persist Nous OAuth credentials as the singleton provider state.
Nous credentials are read from ``providers.nous`` (401 recovery, pool seeding) AND
``credential_pool.nous`` (runtime ``pool.select()``); a pool-only write broke expiry recovery.
So: write the singleton, mirror to the shared store, then ``load_pool("nous")`` upserts the
canonical ``device_code`` entry in place. ``label`` rides in the singleton so re-seeding keeps
it.
"""
from hermes_cli.auth import _save_active_provider_state, _write_shared_nous_state
from agent.credential_pool import load_pool
state = dict(creds)
if label and str(label).strip():
state["label"] = str(label).strip()
_save_active_provider_state("nous", state)
_write_shared_nous_state(state)
pool = load_pool("nous")
return next((e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE), None)
def _sync_nous_pool_from_auth_store() -> None:
"""Best-effort pool reseed after providers.nous changes; never fail login."""
try:
from agent.credential_pool import load_pool
load_pool("nous")
except Exception as exc:
logger.debug("Failed to sync Nous credential pool from auth store: %s", exc)
def _nous_effective_routing(state: Dict[str, Any]) -> tuple[str, str, str, str]:
"""``(portal_url, stored_inference_url, effective_inference_url, client_id)`` from *state*.
The stored inference URL is re-validated network-provenance (persisted); the effective one
layers the runtime-only ``NOUS_INFERENCE_BASE_URL`` override on top and is never persisted.
"""
from hermes_cli.auth import _NOUS_PORTAL_ALLOWED_HOSTS, _optional_base_url
portal_url = (_optional_base_url(state.get("portal_base_url")) or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
# A persisted/stale portal_base_url is where the refresh token gets POSTed — reject any host
# outside the allowlist so a poisoned value can't exfiltrate the bearer, healing to the
# default. Trusted operator env overrides bypass this network-value gate.
env_portal_override = _nous_portal_env_override()
if env_portal_override:
portal_url = env_portal_override.rstrip("/")
else:
parsed_portal_url = urlparse(portal_url)
portal_host, scheme = parsed_portal_url.hostname, parsed_portal_url.scheme
trusted_scheme = scheme == "https" or (
scheme == "http" and portal_host in {"localhost", "127.0.0.1"})
if not portal_host or portal_host not in _NOUS_PORTAL_ALLOWED_HOSTS or not trusted_scheme:
logger.warning(
"auth: ignoring invalid portal_base_url %r "
"(host %r or scheme not allowed), using default",
portal_url, portal_host)
portal_url = DEFAULT_NOUS_PORTAL_URL
# A guest never falls back to the paid host: the gateway cross-refuses an anonymous JWT there
# (400 naming the welcome host), so an absent or disallowed URL heals to the welcome literal.
from hermes_cli.anon_auth import is_guest_state
stored_inference_url = (
_validate_nous_inference_url_from_network(
_optional_base_url(state.get("inference_base_url")))
or (DEFAULT_NOUS_WELCOME_URL if is_guest_state(state) else DEFAULT_NOUS_INFERENCE_URL))
return (
portal_url, stored_inference_url, _nous_inference_env_override() or stored_inference_url,
str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID))
class _NousRuntimeResolve:
"""Working set for one ``resolve_nous_runtime_credentials`` call.
Holds the token pair + routing tuple that shared-store merges / refreshes replace mid-flight.
``persist`` skips writes where only derived TTL countdowns changed (keeps the mtime-keyed
auth-status cache warm) and mirrors every real write to the shared store (best-effort).
"""
def __init__(
self, auth_store: Dict[str, Any], state: Dict[str, Any], state_source_path: Optional[Path],
*, force_refresh: bool, stale_access_token: Optional[str], timeout_seconds: float) -> None:
self.auth_store, self.state, self._source_path = auth_store, state, state_source_path
self.force_refresh, self.stale_access_token = force_refresh, stale_access_token
self.timeout_seconds = timeout_seconds
self.sequence_id = uuid.uuid4().hex[:12]
self._persisted_state = dict(state)
self.persisted_any = False
self.access_token = state.get("access_token")
self.refresh_token = state.get("refresh_token")
self._reload_routing()
def _reload_routing(self) -> None:
(self.portal_base_url, self.stored_inference_base_url, self.inference_base_url,
self.client_id) = _nous_effective_routing(self.state)
def persist(self, reason: str) -> None:
from hermes_cli.auth import _save_provider_state_to_source, _write_shared_nous_state
state = self.state
persisted = _nous_effective_provider_state(self._persisted_state)
if _nous_effective_provider_state(state) == persisted:
_oauth_trace("nous_state_persist_skipped", sequence_id=self.sequence_id, reason=reason)
return
try:
_save_provider_state_to_source(self.auth_store, "nous", state, self._source_path)
except Exception as exc:
_oauth_trace(
"nous_state_persist_failed", sequence_id=self.sequence_id, reason=reason,
error_type=type(exc).__name__)
raise
_oauth_trace(
"nous_state_persisted", sequence_id=self.sequence_id, reason=reason,
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
access_token_fp=_token_fingerprint(state.get("access_token")))
self._persisted_state = dict(state)
self.persisted_any = True
_write_shared_nous_state(state)
def shared_lock(self):
return _nous_shared_store_lock(timeout_seconds=_shared_lock_timeout(self.timeout_seconds))
def has_access_token(self) -> bool:
return isinstance(self.access_token, str) and bool(self.access_token)
def invoke_jwt_status(self) -> Optional[str]:
return _state_invoke_jwt_status(self.state, self.access_token)
def merge_shared(self) -> bool:
"""Adopt fresher shared-store tokens (caller holds the shared lock). True when merged."""
if not _merge_shared_nous_oauth_state(self.state):
return False
self.access_token = self.state.get("access_token")
self.refresh_token = self.state.get("refresh_token")
self._reload_routing()
return True
def skip_refresh_if_peer_rotated(self) -> None:
"""Skip the refresh when a peer already rotated the grant.
Under the store lock: if the bearer that failed upstream is no longer the one on disk and
the on-disk one is usable, adopt it — never re-POST the shared grant.
"""
token = self.access_token
if (self.force_refresh and self.stale_access_token and isinstance(token, str) and token
and token != self.stale_access_token and self.invoke_jwt_status() is None):
_oauth_trace(
"refresh_skipped_peer_rotated", sequence_id=self.sequence_id,
access_token_fp=_token_fingerprint(token))
self.force_refresh = False
def refresh(self, client: httpx.Client, invoke_jwt_status: Optional[str]) -> None:
"""Redeem the refresh token, apply + persist the rotated pair (caller holds both locks)."""
if not isinstance(self.refresh_token, str) or not self.refresh_token:
raise _unusable_invoke_jwt_error(
invoke_jwt_status or "force_refresh", no_refresh_token=True)
refresh_reason = (
"force_refresh" if self.force_refresh else (invoke_jwt_status or "access_unusable"))
_oauth_trace(
"refresh_start", sequence_id=self.sequence_id, reason=refresh_reason,
refresh_token_fp=_token_fingerprint(self.refresh_token))
refreshed = _refresh_nous_or_quarantine(
client=client, auth_store=self.auth_store, state=self.state,
portal_base_url=self.portal_base_url, client_id=self.client_id,
refresh_token=self.refresh_token, reason="runtime_access_refresh_failure",
persist=lambda: self.persist("terminal_runtime_access_refresh_failure"))
previous_refresh_token = self.refresh_token
# The validated, network-provenance URL is what gets persisted (with the rotated tokens,
# so a later JWT validation failure cannot leave the stores on stale metadata). The
# NOUS_INFERENCE_BASE_URL env override is layered on for the client/return value only.
self.stored_inference_base_url = _healed_nous_inference_url(refreshed)
self.inference_base_url = _nous_inference_env_override() or self.stored_inference_base_url
_apply_nous_refreshed_tokens(
self.state, refreshed, self.refresh_token,
inference_base_url=self.stored_inference_base_url)
self.access_token = self.state["access_token"]
self.refresh_token = self.state["refresh_token"]
_oauth_trace(
"refresh_success", sequence_id=self.sequence_id, reason=refresh_reason,
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
new_refresh_token_fp=_token_fingerprint(self.refresh_token))
# Persist immediately so validation failures cannot drop rotated refresh tokens.
self.persist("post_refresh_access_token")
def ensure_usable_access_token(self, client: httpx.Client) -> None:
"""Merge from the shared store / refresh until the access token is a usable invoke JWT."""
from hermes_cli.anon_auth import is_guest_state, refresh_guest_state
if is_guest_state(self.state):
# Guest seam: the anon_ credential is the refresh material; re-exchange instead of
# redeeming a rotating refresh token. Quarantine never applies to a guest.
if self.force_refresh or self.invoke_jwt_status() is not None:
refresh_guest_state(self.state, client)
self.access_token = self.state["access_token"]
self.stored_inference_base_url = self.state.get("inference_base_url") or self.stored_inference_base_url
self.inference_base_url = _nous_inference_env_override() or self.stored_inference_base_url
self.persist("guest_exchange")
return
if not self.has_access_token():
with self.shared_lock():
if self.merge_shared():
self.persist("runtime_shared_merge_missing_access_token")
if not self.has_access_token():
raise _nous_err(
"No access token found for Nous Portal login.", "nous_auth_missing_access_token", relogin=True)
invoke_jwt_status = self.invoke_jwt_status()
self.skip_refresh_if_peer_rotated()
if not (self.force_refresh or invoke_jwt_status is not None):
return
with self.shared_lock():
if self.merge_shared():
invoke_jwt_status = self.invoke_jwt_status()
self.persist("post_shared_merge_access_unusable")
self.skip_refresh_if_peer_rotated()
if self.force_refresh or invoke_jwt_status is not None:
self.refresh(client, invoke_jwt_status)
def resolve_nous_runtime_credentials(
*, timeout_seconds: float = 15.0, insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None, force_refresh: bool = False,
stale_access_token: Optional[str] = None) -> Dict[str, Any]:
"""Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock).
A guest whose ``anon_`` credential NAS no longer knows (reaped or claimed) is retired and a new
identity is set up once, transparently -- the one client rule covering both reap and claim.
"""
from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest, ensure_portal_identity
try:
return _resolve_nous_runtime_credentials(
timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle,
force_refresh=force_refresh, stale_access_token=stale_access_token)
except AnonCredentialDead as dead_exc:
from hermes_cli.auth import get_provider_auth_state
from hermes_cli.anon_auth import ANON_ACCOUNT_LOCKED
dead = get_provider_auth_state("nous") or {}
clear_dead_guest(str(dead_exc.code or "anon_credential_dead"), dead_token=dead.get("anon_token"))
# A locked account is retired but never silently replaced: the way forward is a sign-in.
if dead_exc.code == ANON_ACCOUNT_LOCKED:
raise
if ensure_portal_identity(explicit=True, timeout_seconds=timeout_seconds) is None:
raise
return _resolve_nous_runtime_credentials(
timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle)
def _resolve_nous_runtime_credentials(
*, timeout_seconds: float = 15.0, insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None, force_refresh: bool = False,
stale_access_token: Optional[str] = None) -> Dict[str, Any]:
"""Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock).
``stale_access_token`` is the bearer that just failed upstream (401): with ``force_refresh``,
the refresh POST is skipped if the store (re-read under the lock) already holds a *different*
usable token — a peer won the rotation; adopt it rather than invalidate a sibling's token.
"""
from hermes_cli.auth import (
_assert_nous_inference_jwt_usable, _auth_file_path, _provider_state_transaction,
_resolve_verify, _select_nous_invoke_jwt, _sync_nous_pool_from_auth_store,
_tls_state_from_verify)
with _provider_state_transaction("nous") as (auth_store, state, state_source_path):
if not state:
raise _nous_err("Hermes is not logged into Nous Portal.", "nous_auth_missing", relogin=True)
run = _NousRuntimeResolve(
auth_store, state, state_source_path, force_refresh=force_refresh,
stale_access_token=stale_access_token, timeout_seconds=timeout_seconds)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
_oauth_trace(
"nous_runtime_credentials_start", sequence_id=run.sequence_id,
refresh_token_fp=_token_fingerprint(state.get("refresh_token")))
with _nous_http_client(timeout_seconds or 15.0, verify) as client:
run.ensure_usable_access_token(client)
_assert_nous_inference_jwt_usable(state, access_token=run.access_token)
_select_nous_invoke_jwt(
state, access_token=run.access_token, sequence_id=run.sequence_id)
# Persist routing and TLS metadata for non-interactive refresh — the validated,
# network-provenance URL, NEVER the env override (a runtime-only overlay; persisting
# it would leak a dev/staging host into auth.json and survive unsetting it).
state.update(
portal_base_url=run.portal_base_url, client_id=run.client_id,
inference_base_url=run.stored_inference_base_url,
tls=_tls_state_from_verify(verify))
run.persist("resolve_nous_runtime_credentials_final")
if run.persisted_any:
_sync_nous_pool_from_auth_store()
api_key = state.get("agent_key")
if not isinstance(api_key, str) or not api_key:
raise _nous_err("Failed to resolve a Nous inference API key", "server_error")
expires_at = state.get("agent_key_expires_at")
return {
"provider": "nous", "base_url": run.inference_base_url, "api_key": api_key,
"key_id": state.get("agent_key_id"), "expires_at": expires_at,
"expires_in": _remaining_ttl(expires_at, state.get("agent_key_expires_in")),
"source": NOUS_AUTH_PATH_INVOKE_JWT,
# Public semantic source label; the concrete store is exposed separately for diagnostics.
# Refresh persistence uses state_source_path internally and must not overload this field.
"auth_path": NOUS_AUTH_PATH_INVOKE_JWT,
"state_path": str(state_source_path or _auth_file_path())}
def _empty_nous_auth_status() -> Dict[str, Any]:
return {
"logged_in": False, "portal_base_url": None, "inference_base_url": None,
"access_expires_at": None, "agent_key_expires_at": None, "has_refresh_token": False,
"inference_credential_present": False, "credential_source": None}
def _snapshot_nous_pool_status() -> Dict[str, Any]:
"""Best-effort status from the credential pool.
Fallback only: the auth-store provider state is the runtime source of truth because it is what
``resolve_nous_runtime_credentials()`` refreshes.
"""
from hermes_cli.auth import _parse_iso_timestamp
try:
from agent.credential_pool import load_pool
pool = load_pool("nous")
entries = list(pool.entries()) if pool and pool.has_credentials() else []
if not entries:
return _empty_nous_auth_status()
entry = max(entries, key=lambda e: (
_parse_iso_timestamp(getattr(e, "agent_key_expires_at", None)) or 0.0,
_parse_iso_timestamp(getattr(e, "expires_at", None)) or 0.0,
-int(getattr(e, "priority", 0) or 0)))
attr = lambda name, default=None: getattr(entry, name, default) # noqa: E731
if not attr("runtime_api_key"):
return _empty_nous_auth_status()
access_token, refresh_token = attr("access_token"), attr("refresh_token")
auth_type = str(attr("auth_type", "") or "").strip().lower()
is_portal_oauth = bool(access_token) and (
auth_type.startswith("oauth") or bool(refresh_token))
label = attr("label", "unknown")
return {
"logged_in": is_portal_oauth,
"portal_base_url": (
(attr("portal_base_url") or DEFAULT_NOUS_PORTAL_URL) if is_portal_oauth else None),
"inference_base_url": (
attr("inference_base_url") or attr("runtime_base_url") or attr("base_url")),
"access_token": access_token if is_portal_oauth else None,
"access_expires_at": attr("expires_at"),
"agent_key_expires_at": attr("agent_key_expires_at"),
"has_refresh_token": bool(refresh_token), "inference_credential_present": True,
"credential_source": f"pool:{label}", "source": f"pool:{label}"}
except Exception:
return _empty_nous_auth_status()
def _nous_status_from_state(
state: Dict[str, Any], *, logged_in: bool, source: str) -> Dict[str, Any]:
"""Auth-store-backed Nous status snapshot (shared by the live and refresh-free variants)."""
from hermes_cli.anon_auth import is_guest_state
access_token = state.get("access_token")
account_tier = state.get("account_tier")
return {
"logged_in": logged_in, "portal_base_url": state.get("portal_base_url"),
"inference_base_url": state.get("inference_base_url"),
"access_expires_at": state.get("expires_at"),
"agent_key_expires_at": state.get("agent_key_expires_at"),
"has_refresh_token": bool(state.get("refresh_token")), "access_token": access_token,
"inference_credential_present": bool(access_token or state.get("agent_key")),
"credential_source": "auth_store", "source": source,
# Free tier: display surfaces render it with the free-tier copy, never as an account login.
"account_tier": account_tier if isinstance(account_tier, str) else None,
"free_tier": is_guest_state(state)}
def _compute_nous_auth_status() -> Dict[str, Any]:
"""Uncached implementation of get_nous_auth_status(). See that function."""
from hermes_cli.auth import get_provider_auth_state, resolve_nous_runtime_credentials
state = get_provider_auth_state("nous")
if not state:
return _snapshot_nous_pool_status()
base_status = _nous_status_from_state(
state, logged_in=bool(state.get("access_token")), source="auth_store")
try:
creds = resolve_nous_runtime_credentials()
refreshed_state = get_provider_auth_state("nous") or state
base_status.update({
"logged_in": True,
"portal_base_url": (
refreshed_state.get("portal_base_url") or base_status.get("portal_base_url")),
"inference_base_url": (
creds.get("base_url") or refreshed_state.get("inference_base_url")
or base_status.get("inference_base_url")),
"access_expires_at": (
refreshed_state.get("expires_at") or base_status.get("access_expires_at")),
"agent_key_expires_at": (
creds.get("expires_at") or refreshed_state.get("agent_key_expires_at")
or base_status.get("agent_key_expires_at")),
"has_refresh_token": bool(refreshed_state.get("refresh_token")),
"inference_credential_present": True, "credential_source": "auth_store",
"source": f"runtime:{creds.get('source', 'portal')}", "key_id": creds.get("key_id")})
except AuthError as exc:
base_status.update({
"logged_in": False, "error": str(exc),
"relogin_required": bool(getattr(exc, "relogin_required", False)),
"error_code": getattr(exc, "code", None)})
return base_status
def _terminal_quarantine_marker(state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""The persisted ``last_auth_error`` when it is a terminal quarantine with no credential left.
Only terminal while there is no usable credential: if a later login repopulated tokens the stale
marker must not keep reporting terminal.
"""
last_err = state.get("last_auth_error")
if (isinstance(last_err, dict) and last_err.get("relogin_required")
and not (state.get("access_token") or state.get("refresh_token"))):
return last_err
return None
def get_nous_auth_status_local() -> Dict[str, Any]:
"""Refresh-free Nous auth snapshot for read-only display surfaces.
NEVER calls ``resolve_nous_runtime_credentials()`` (no refresh POST / single-use token spent);
``logged_in`` = usable invoke JWT, or a refresh token not terminally quarantined — not proof
the server still accepts it.
"""
from hermes_cli.auth import get_provider_auth_state
try:
state = get_provider_auth_state("nous")
except Exception:
state = None
if not state:
return _snapshot_nous_pool_status()
jwt_reason = _state_invoke_jwt_status(state, state.get("access_token"))
last_err = _terminal_quarantine_marker(state)
logged_in = (jwt_reason is None) or (bool(state.get("refresh_token")) and last_err is None)
status = _nous_status_from_state(state, logged_in=logged_in, source="auth_store_local")
if last_err is not None:
status.update(
relogin_required=True, error_code=last_err.get("code"),
error=last_err.get("message") or "re-login required")
return status
# Enum values reported on the dashboard /api/status as ``nous_session_valid``. NAS's health sweep
# re-mints the bootstrap session ONLY on "terminal"; "valid" and "unknown" are no-ops. Keep this
# set small and stable — NAS parses it permissively, so new members are non-breaking but rare.
NOUS_SESSION_VALID = "valid"
NOUS_SESSION_TERMINAL = "terminal"
NOUS_SESSION_UNKNOWN = "unknown"
def get_nous_session_validity() -> str:
"""Classify the Nous bootstrap session for the dashboard /api/status probe.
Local auth-store state only; polled frequently, so it never resolves or refreshes. ANTI-FLAP:
only a *terminal* failure maps to "terminal" — a rotation blip, network error, or expiring
token must NOT (that would trigger a spurious NAS re-mint on a healthy box).
"""
from hermes_cli.auth import get_provider_auth_state
try:
state = get_provider_auth_state("nous")
except Exception:
state = None
if not state:
return NOUS_SESSION_UNKNOWN
# The persisted quarantine marker (`last_auth_error.relogin_required=True`, written when the
# refresh path clears dead tokens) is the strongest, most stable terminal signal — report
# "terminal" even after the in-memory AuthError is long gone.
if _terminal_quarantine_marker(state) is not None:
return NOUS_SESSION_TERMINAL
if _state_invoke_jwt_status(state, state.get("access_token")) is None:
return NOUS_SESSION_VALID
# Missing, malformed, expired, or merely expiring credentials are not proof of a terminal
# session. Runtime paths own refreshes; the health endpoint stays side-effect free.
return NOUS_SESSION_UNKNOWN
def _pool_first_oauth_status(
provider_id: str, *, is_expiring: Callable[[str, int], bool], auth_mode: str,
resolve: Callable[[], Dict[str, Any]],
on_pool_miss: Optional[Callable[[], Optional[Dict[str, Any]]]] = None) -> Dict[str, Any]:
"""Status snapshot for a store-backed OAuth provider (Codex, xAI).
Pool first (where `hermes auth` / `hermes model` store device_code tokens), then
*on_pool_miss* for a pool-derived degraded status, then the legacy state via *resolve*.
The pool read is an observation (``peek``), not a lease: ``select()`` refreshes an expiring
single-use token and, when that speculative POST fails transiently, benches the entry with a
persisted cooldown — every credential-gated listing (``/model`` picker, doctor) then shows the
provider as unconfigured while the runtime resolver still serves it. Refreshing stays with the
runtime resolver reached through *resolve*, whose failures persist nothing.
"""
from hermes_cli.auth import _auth_file_path
try:
from agent.credential_pool import load_pool
pool = load_pool(provider_id)
if pool and pool.has_credentials():
entry = pool.peek()
if entry is not None:
api_key = (
getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", ""))
if api_key and not is_expiring(api_key, 0):
return {
"logged_in": True, "auth_store": str(_auth_file_path()),
"last_refresh": getattr(entry, "last_refresh", None),
"auth_mode": auth_mode,
"source": f"pool:{getattr(entry, 'label', 'unknown')}", "api_key": api_key,
# The host this entry's key belongs to, so a caller never pairs it with
# another provider default (#121486).
"base_url": str(getattr(entry, "runtime_base_url", None)
or getattr(entry, "base_url", None) or "").rstrip("/")}
if on_pool_miss is not None and (degraded := on_pool_miss()):
return degraded
except Exception:
pass
try:
creds = resolve()
return {
"logged_in": True, "auth_store": str(_auth_file_path()),
"last_refresh": creds.get("last_refresh"),
"auth_mode": creds.get("auth_mode"), "source": creds.get("source"),
"api_key": creds.get("api_key"), "base_url": creds.get("base_url") or ""}
except AuthError as exc:
return {"logged_in": False, "auth_store": str(_auth_file_path()), "error": str(exc)}
def _nous_device_code_login(
*, portal_base_url: Optional[str] = None, inference_base_url: Optional[str] = None,
client_id: Optional[str] = None, scope: Optional[str] = None, open_browser: bool = True,
timeout_seconds: float = 15.0, insecure: bool = False, ca_bundle: Optional[str] = None,
on_verification: Optional[Callable[[str, str], None]] = None) -> Dict[str, Any]:
"""Run the Nous device-code flow and return full OAuth state without persisting."""
from hermes_cli.auth import (
PROVIDER_REGISTRY, _coerce_ttl_seconds, _is_remote_session, _optional_base_url,
_poll_for_token, _print_device_code_instructions, _request_device_code,
_tls_state_from_verify, format_auth_error, refresh_nous_oauth_from_state)
pconfig = PROVIDER_REGISTRY["nous"]
portal_base_url = (
portal_base_url or os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL")
or pconfig.portal_base_url).rstrip("/")
requested_inference_url = (
inference_base_url or os.getenv("NOUS_INFERENCE_BASE_URL")
or pconfig.inference_base_url).rstrip("/")
client_id = client_id or pconfig.client_id
scope = scope or pconfig.scope
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
if _is_remote_session():
open_browser = False
print(f"Starting Hermes login via {pconfig.name}...")
print(f"Portal: {portal_base_url}")
if insecure:
print("TLS verification: disabled (--insecure)")
elif ca_bundle:
print(f"TLS verification: custom CA bundle ({ca_bundle})")
with _nous_http_client(timeout_seconds, verify) as client:
device_data = _request_device_code(
client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope)
verification_url = str(device_data["verification_uri_complete"])
user_code = str(device_data["user_code"])
expires_in = int(device_data["expires_in"])
interval = int(device_data["interval"])
_print_device_code_instructions(
verification_url, user_code, open_browser=open_browser, failure_dash="—")
# Out-of-band consumer (e.g. the TUI gateway, whose stdout is a JSON-RPC pipe): fired AFTER
# the print/browser block and BEFORE polling so it can render the link while we wait.
if on_verification is not None:
with suppress(Exception):
on_verification(verification_url, user_code)
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
print(f"Waiting for approval (polling every {effective_interval}s)...")
token_data = _poll_for_token(
client=client, portal_base_url=portal_base_url, client_id=client_id,
device_code=str(device_data["device_code"]), expires_in=expires_in,
poll_interval=interval)
now = datetime.now(timezone.utc)
token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0))
resolved_inference_url = (
_optional_base_url(token_data.get("inference_base_url")) or requested_inference_url)
if resolved_inference_url != requested_inference_url:
print(f"Using portal-provided inference URL: {resolved_inference_url}")
auth_state = {
"portal_base_url": portal_base_url, "inference_base_url": resolved_inference_url,
"client_id": client_id, "scope": token_data.get("scope") or scope,
"token_type": token_data.get("token_type", "Bearer"),
"access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"),
"obtained_at": now.isoformat(), "expires_at": _iso_after(now, token_expires_in),
"expires_in": token_expires_in, "tls": _tls_state_from_verify(verify),
**_NOUS_EMPTY_AGENT_KEY_FIELDS}
try:
return refresh_nous_oauth_from_state(
auth_state, timeout_seconds=timeout_seconds, force_refresh=False)
except AuthError as exc:
if exc.code == "subscription_required":
portal_url = auth_state.get("portal_base_url", DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print()
print(format_auth_error(exc))
print(f" Subscribe here: {portal_url}/billing")
print()
print("After subscribing, run `hermes model` again to finish setup.")
raise SystemExit(1)
raise
def _mirror_nous_state_best_effort(auth_state: Dict[str, Any]) -> None:
"""Mirror to the shared store + reseed the pool, swallowing all errors (same as _login_nous)."""
from hermes_cli.auth import _sync_nous_pool_from_auth_store, _write_shared_nous_state
with suppress(Exception):
_write_shared_nous_state(auth_state)
with suppress(Exception):
_sync_nous_pool_from_auth_store()
def step_up_nous_billing_scope(
*, open_browser: bool = True, timeout_seconds: float = 15.0,
on_verification: Optional[Callable[[str, str], None]] = None) -> bool:
"""Re-run the device flow requesting ``billing:manage`` (step-up on 403 insufficient_scope).
The user must be ADMIN/OWNER and select "Allow Remote Spending" in the portal, else the
server silently downscopes and this returns False. Persists like ``_login_nous`` minus the
model picker.
"""
from hermes_cli.auth import (
PROVIDER_REGISTRY, _nous_device_code_login, _save_active_provider_state,
get_provider_auth_state)
prior = get_provider_auth_state("nous") or {}
pconfig = PROVIDER_REGISTRY["nous"]
# Step-up scope: existing scopes (if any) + billing:manage, deduped, order-stable. Falls back
# to the standard inference+billing set.
_raw_scope = prior.get("scope")
prior_scope = _raw_scope.split() if isinstance(_raw_scope, str) else []
requested = list(dict.fromkeys([
*(prior_scope or [NOUS_INFERENCE_INVOKE_SCOPE]), NOUS_BILLING_MANAGE_SCOPE]))
auth_state = _nous_device_code_login(
portal_base_url=prior.get("portal_base_url") or None,
inference_base_url=prior.get("inference_base_url") or None,
client_id=prior.get("client_id") or pconfig.client_id, scope=" ".join(requested),
open_browser=open_browser, timeout_seconds=timeout_seconds, on_verification=on_verification)
_save_active_provider_state("nous", auth_state)
_mirror_nous_state_best_effort(auth_state)
granted = auth_state.get("scope")
return isinstance(granted, str) and NOUS_BILLING_MANAGE_SCOPE in granted.split()
def _pick_nous_model_after_login(
auth_state: Dict[str, Any], inference_base_url: str) -> Optional[str]:
"""Fetch the curated Nous model list (tier/policy-filtered) and run the interactive picker.
Returns the selected model id, or None when the user skipped / nothing was selectable.
Raises on any fetch failure so the caller can print the "Login succeeded, but..." notice.
"""
from hermes_cli.auth import _prompt_model_selection
runtime_key = auth_state.get("agent_key") or auth_state.get("access_token")
if not isinstance(runtime_key, str) or not runtime_key:
raise _nous_err("No runtime API key available to fetch models", "invalid_token")
from hermes_cli.models import (
get_curated_nous_model_ids,
check_nous_free_tier,
partition_nous_models_by_tier,
union_with_portal_free_recommendations,
union_with_portal_paid_recommendations,
)
from hermes_cli.models_pricing import (
get_pricing_for_provider,
nous_policy_allowed_ids,
restrict_to_nous_policy,
)
model_ids = get_curated_nous_model_ids()
_portal = auth_state.get("portal_base_url", "")
print()
unavailable_models: list = []
unavailable_message = ""
_policy_narrowed = False
if model_ids:
pricing = get_pricing_for_provider("nous")
# Force fresh account data so recent credit purchases are reflected immediately.
free_tier = check_nous_free_tier(force_fresh=True)
# Narrow before the tier split, so a rescued id still has to pass the free/paid predicate.
_policy_allowed = nous_policy_allowed_ids()
if free_tier:
with suppress(Exception):
unavailable_message = _portal_entitlement_message("paid Nous models")
# The Portal's free/paidRecommendedModels endpoint is the source of truth for what's
# available *right now*: newly-launched models show without a CLI release.
union = (
union_with_portal_free_recommendations if free_tier
else union_with_portal_paid_recommendations)
model_ids, pricing = union(model_ids, pricing, _portal)
_before_policy = model_ids
model_ids = restrict_to_nous_policy(model_ids, _policy_allowed, rescue_empty=True)
_policy_narrowed = model_ids != _before_policy
if free_tier:
model_ids, unavailable_models = partition_nous_models_by_tier(
model_ids, pricing, free_tier=True)
if model_ids:
from hermes_cli.nous_account import nous_policy_notice
_policy_notice = nous_policy_notice(removed=_policy_narrowed)
if _policy_notice:
print(_policy_notice)
print(
f"Showing {len(model_ids)} curated models — "
"use \"Enter custom model name\" for others.")
return _prompt_model_selection(
model_ids, pricing=pricing, unavailable_models=unavailable_models, portal_url=_portal,
unavailable_message=unavailable_message, confirm_provider="nous",
confirm_base_url=inference_base_url, confirm_api_key=runtime_key)
if unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print("No free models currently available.")
print(unavailable_message or f"Upgrade at {_url} to access paid models.")
else:
print("No curated models available for Nous Portal.")
return None
def _offer_shared_nous_import(timeout_seconds: float) -> Optional[Dict[str, Any]]:
"""Codex-style auto-import: offer to rehydrate a Nous credential from another profile.
Checks the shared store before launching a fresh device-code flow. Returns the refreshed
auth state when the user accepted and the import succeeded, else None.
"""
from hermes_cli.auth import _prompt_yes_no, _read_shared_nous_state
from hermes_cli.anon_auth import is_guest_state
shared = _read_shared_nous_state()
if not shared or is_guest_state(shared):
# A free-tier identity is not an OAuth credential to import; a real sign-in replaces it
# (persist_nous_credentials overwrites the singleton and the shared store).
return None
try:
shared_path = _nous_shared_store_path()
except RuntimeError:
shared_path = None
print()
print(f"Found existing Nous OAuth credentials at {shared_path}" if shared_path
else "Found existing shared Nous OAuth credentials")
if not _prompt_yes_no("Import these credentials? [Y/n]: ", default="y"):
return None
print("Rehydrating Nous session from shared credentials...")
auth_state = _try_import_shared_nous_state(timeout_seconds=timeout_seconds)
if auth_state is None:
print("Could not refresh shared credentials — falling back to device-code login.")
return auth_state
def _restore_active_provider(prior_active_provider: Any) -> None:
"""Undo the ``active_provider="nous"`` that ``_save_provider_state`` wrote during login."""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
with _auth_store_lock():
auth_store = _load_auth_store()
if prior_active_provider:
auth_store["active_provider"] = prior_active_provider
else:
auth_store.pop("active_provider", None)
_save_auth_store(auth_store)
def _login_nous(args, pconfig: ProviderConfig) -> None:
"""Nous Portal device authorization flow."""
from hermes_cli.auth import (
_auth_store_lock, _load_auth_store, _nous_device_code_login, _save_active_provider_state,
_save_model_choice, _sync_nous_pool_from_auth_store, _update_config_for_provider,
_write_shared_nous_state, format_auth_error)
timeout_seconds = getattr(args, "timeout", None) or 15.0
ca_bundle = (
getattr(args, "ca_bundle", None) or os.getenv("HERMES_CA_BUNDLE")
or os.getenv("SSL_CERT_FILE"))
try:
auth_state = _offer_shared_nous_import(timeout_seconds)
if auth_state is None:
auth_state = _nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
client_id=getattr(args, "client_id", None) or pconfig.client_id,
scope=getattr(args, "scope", None),
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=timeout_seconds, insecure=bool(getattr(args, "insecure", False)),
ca_bundle=ca_bundle)
inference_base_url = auth_state["inference_base_url"]
# Snapshot BEFORE _save_provider_state overwrites active_provider to "nous", so a
# model-picker "Skip (keep current)" can restore the user's previous provider.
with _auth_store_lock():
prior_active_provider = _load_auth_store().get("active_provider")
saved_to = _save_active_provider_state("nous", auth_state)
# Mirror to the shared store so other profiles can one-tap import (best-effort inside).
_write_shared_nous_state(auth_state)
_sync_nous_pool_from_auth_store()
print()
print("Login successful!")
print(f" Auth state: {saved_to}")
# Pick the model BEFORE writing the provider to config.yaml so config is never half-updated.
selected_model = None
try:
selected_model = _pick_nous_model_after_login(auth_state, inference_base_url)
except Exception as exc:
message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
print()
print(f"Login succeeded, but could not fetch available models. Reason: {message}")
# No model (Skip, fetch failed, nothing curated): keep the previous provider rather than
# switch to Nous with a mismatched model; the Nous tokens stay saved for future use.
if not selected_model:
_restore_active_provider(prior_active_provider)
print()
print("No provider change. Nous credentials saved for future use.")
print(" Run `hermes model` again to switch to Nous Portal.")
return
config_path = _update_config_for_provider(
"nous", inference_base_url, default_model=selected_model)
_save_model_choice(selected_model)
print(f"Default model set to: {selected_model}")
print(f" Config updated: {config_path} (model.provider=nous)")
except KeyboardInterrupt:
print("\nLogin cancelled.")
raise SystemExit(130)
except Exception as exc:
from hermes_cli.auth_error_copy import sign_in_failure_lines
logger.debug("nous login failed: %r", exc)
print()
for line in sign_in_failure_lines(exc, service_host=_portal_host(getattr(args, "portal_url", None))):
print(line)
raise SystemExit(1)
def _portal_host(portal_url: Optional[str]) -> str:
return urlparse(portal_url or DEFAULT_NOUS_PORTAL_URL).hostname or "portal.nousresearch.com"