Files
hermes-agent/hermes_cli/auth_nous.py
T

2151 lines
85 KiB
Python

"""Nous Portal OAuth: device-code login, refresh, shared-store mirroring, JWT selection, status.
Split out of ``hermes_cli/auth.py``; every moved name is re-imported there, so
``hermes_cli.auth.<name>`` keeps resolving (and monkeypatching) as before. Origin-internal
helpers are imported lazily inside each function (no import cycle; patches on
``hermes_cli.auth.<helper>`` still intercept).
"""
from __future__ import annotations
import logging
import hashlib
import json
import os
import threading
import time
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import 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,
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,
)
from typing import TYPE_CHECKING
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")
def _token_fingerprint(token: Any) -> Optional[str]:
"""Return a short hash fingerprint for telemetry without leaking token bytes."""
if not isinstance(token, str):
return None
cleaned = token.strip()
if not cleaned:
return None
return hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:12]
def _oauth_trace_enabled() -> bool:
raw = os.getenv("HERMES_OAUTH_TRACE", "").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _oauth_trace(event: str, *, sequence_id: Optional[str] = None, **fields: Any) -> None:
if not _oauth_trace_enabled():
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 _format_nous_entitlement_auth_error(error: AuthError) -> str:
try:
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)
message = format_nous_portal_entitlement_message(
account_info,
capability="Nous model access",
)
if message:
return message
except Exception:
pass
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:
parsed = urlparse(stored)
if parsed.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 is willing to forward inference
# JWTs to. Sending a bearer anywhere else would leak it.
#
# This is consulted only for URLs coming from the NETWORK side (Portal
# refresh responses). User-controlled env-var overrides
# (NOUS_INFERENCE_BASE_URL) bypass validation — that's the documented
# dev/staging escape hatch and the env source is already trusted (the
# user set it themselves).
_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({
"inference-api.nousresearch.com",
})
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 from the Portal API (MITM, malicious response
injection) could otherwise redirect every subsequent proxy request — bearing the user's
inference JWT — to an attacker-controlled endpoint.
"""
if not isinstance(url, str):
return None
cleaned = url.strip()
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 parsed.hostname not in _ALLOWED_NOUS_INFERENCE_HOSTS:
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 _nous_inference_env_override() -> Optional[str]:
"""Return the user-set ``NOUS_INFERENCE_BASE_URL`` override, if any.
Documented dev/staging escape hatch. The env source is trusted (the OS user set it), so unlike
Portal-returned URLs it is intentionally NOT gated by the network host allowlist.
Returns a trailing-slash-stripped string, or ``None`` when unset/blank.
"""
from hermes_cli.auth import _optional_base_url
return _optional_base_url(os.getenv("NOUS_INFERENCE_BASE_URL"))
def _nous_portal_env_override() -> Optional[str]:
"""Return the user/deployment-set Portal base URL override, if any.
``HERMES_PORTAL_BASE_URL`` / ``NOUS_PORTAL_BASE_URL`` are the documented dev/staging escape
hatch (e.g. hosted agents on the staging Portal). Like the inference override, the env source
is trusted and must NOT be gated by ``_NOUS_PORTAL_ALLOWED_HOSTS``: that allowlist rejects an
untrusted NETWORK-provided value persisted to auth.json, not one the operator configured.
"""
from hermes_cli.auth import _optional_base_url
return _optional_base_url(
os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL")
)
def _scope_values(raw_scope: Any) -> set[str]:
# OAuth token responses normally return a space-separated string. Keep
# collection support for JWT ``scp`` claims and older stored test fixtures.
scopes: set[str] = set()
if isinstance(raw_scope, str):
for part in raw_scope.replace(",", " ").split():
cleaned = part.strip()
if cleaned:
scopes.add(cleaned)
elif isinstance(raw_scope, (list, tuple, set, frozenset)):
for item in raw_scope:
if isinstance(item, str):
scopes.update(_scope_values(item))
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)):
if float(exp) <= (time.time() + skew):
return "invoke_jwt_expiring"
return None
if _is_expiring(expires_at, skew):
return "invoke_jwt_expiry_unknown_or_expiring"
return 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 _assert_nous_inference_jwt_usable(
state: Dict[str, Any],
*,
access_token: Any = None,
) -> None:
from hermes_cli.auth import _nous_invoke_jwt_status
token = state.get("access_token") if access_token is None else access_token
reason = _nous_invoke_jwt_status(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
if reason is None:
return
raise _nous_err(
"Nous Portal access token is not a usable inference JWT "
f"({reason}). Re-authenticate with: hermes auth add nous",
reason, relogin=True,
)
def _log_nous_invoke_jwt_selected(
*,
access_token: Any,
sequence_id: Optional[str] = None,
) -> None:
logger.debug("Nous inference auth: using NAS invoke JWT")
_oauth_trace(
"nous_invoke_jwt_selected",
sequence_id=sequence_id,
access_token_fp=_token_fingerprint(access_token),
)
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)):
try:
return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat()
except Exception:
pass
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 _coerce_ttl_seconds, _nonempty_str, _parse_iso_timestamp
access_token = state.get("access_token")
if not _nonempty_str(access_token):
return
now = datetime.now(timezone.utc)
existing_obtained_at = state.get("agent_key_obtained_at")
if obtained_at:
effective_obtained_at = obtained_at
elif (
state.get("agent_key") == access_token
and isinstance(existing_obtained_at, str)
and existing_obtained_at.strip()
):
effective_obtained_at = existing_obtained_at
else:
effective_obtained_at = now.isoformat()
expires_at = _nous_jwt_expires_at(access_token, state.get("expires_at"))
expires_epoch = _parse_iso_timestamp(expires_at)
expires_in = (
max(0, int(expires_epoch - time.time()))
if expires_epoch is not None
else _coerce_ttl_seconds(state.get("expires_in"))
)
if expires_at:
state["expires_at"] = expires_at
state["expires_in"] = expires_in
state["agent_key"] = access_token
state["agent_key_id"] = None
state["agent_key_expires_at"] = expires_at
state["agent_key_expires_in"] = expires_in
state["agent_key_reused"] = False
state["agent_key_obtained_at"] = effective_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)
_log_nous_invoke_jwt_selected(
access_token=state.get("access_token"),
sequence_id=sequence_id,
)
_NOUS_EFFECTIVE_STATE_IGNORED_KEYS = frozenset({
# These are derived from expires_at/JWT exp and naturally tick down between
# reads. Persisting only these changes makes auth.json noisy and defeats
# the mtime-keyed auth-status cache.
"expires_in",
"agent_key_expires_in",
})
def _nous_effective_provider_state(state: Dict[str, Any]) -> Dict[str, Any]:
return {
key: value
for key, value in state.items()
if key 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:
"""Resolve the directory that holds the shared Nous token store.
Honors ``HERMES_SHARED_AUTH_DIR`` so tests can redirect it. Defaults to
``<hermes-root>/shared/`` (``~/.hermes/shared/`` on POSIX, ``%LOCALAPPDATA%\\hermes\\shared\\``
on Windows), outside any named profile so all profiles under one root share the store.
The store lets ``hermes --profile <name> auth add nous --type oauth`` one-tap import instead
of re-running device-code. It is written on login AND on every runtime refresh so the stored
refresh_token stays current when one profile rotates it; a server-side stale token just makes
the import fail gracefully and falls back to the device-code flow.
"""
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: if pytest is running and this resolves to a path under the
# real user's Hermes root, refuse rather than silently corrupt cross-profile
# state. Tests must set HERMES_SHARED_AUTH_DIR to a tmp_path (conftest
# does not do this automatically — mirror the _auth_file_path() guard
# so forgetting to set it fails loudly instead of writing to the real
# shared store).
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:
# No HERMES_HOME yet (pre-setup): fall through without locking.
yield
return
with _file_lock(
lock_path,
_nous_shared_lock_holder,
timeout_seconds,
"Timed out waiting for shared Nous auth lock",
):
yield
# 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",
)
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()
if not shared:
return False
shared_refresh = shared.get("refresh_token")
if not _nonempty_str(shared_refresh):
return False
local_refresh = state.get("refresh_token")
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(local_refresh or "").strip()
fresher_access = shared_access_exp > local_access_exp
if not refresh_changed and not fresher_access:
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,
"inference_base_url": src.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
"obtained_at": src.get("obtained_at"),
"expires_at": src.get("expires_at"),
}
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: any failure is swallowed after logging. The shared store is a convenience layer;
the per-profile auth.json remains the source of truth.
"""
from hermes_cli.auth import _nonempty_str, _write_private_file_atomic
refresh_token = state.get("refresh_token")
access_token = state.get("access_token")
# No refresh_token = nothing worth sharing across profiles
if not (_nonempty_str(refresh_token) and _nonempty_str(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()
_write_private_file_atomic(
path, json.dumps(shared, indent=2, sort_keys=True), replace=os.replace,
)
_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]]:
"""Return the shared Nous OAuth state if present and well-formed.
Returns ``None`` when the file is missing, unreadable, malformed, or lacks required fields;
callers treat that as "no shared credentials, fall through to device-code".
"""
from hermes_cli.auth import _nonempty_str
try:
path = _nous_shared_store_path()
except RuntimeError:
# Test seat belt tripped — treat as missing
return None
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
if not (_nonempty_str(payload.get("refresh_token")) and _nonempty_str(payload.get("access_token"))):
return None
return payload
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():
path = _nous_shared_store_path()
try:
path.unlink()
except FileNotFoundError:
pass
_oauth_trace("nous_shared_store_cleared", reason=reason)
except Exception as exc:
logger.debug("Failed to clear shared Nous auth store: %s", exc)
# Error codes per provider for which retrying the SAME refresh token cannot succeed.
# ``*_refresh_failed`` covers HTTP 400/401/403 from the token endpoint (invalid_grant, token
# revoked, refresh_token_reused); ``*_auth_missing_refresh_token`` means the pool entry has no
# refresh token at all. All must also carry ``relogin_required=True``; transient failures
# (429, 5xx) do not.
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."""
from hermes_cli.auth import _FLAT_OAUTH_TOKEN_KEYS, _auth_file_path, _last_auth_error_marker, invalidate_nous_auth_status_cache
# Forensic logging BEFORE we clear the token material. A hosted agent
# can take a terminal invalid_grant and get quarantined here silently: the
# only downstream signal is a "No access token found" WARNING once the pool
# is already empty, which is too late to root-cause. A managed log drain may
# be WARNING-only, so this MUST be logger.warning (INFO never reaches it).
#
# Redaction safety: emit ONLY the 12-char SHA-256 hex prefix of the refresh
# token (correlates to NAS's refreshTokenHash without leaking the secret) plus
# sizes/booleans. NEVER pass a raw token/agent_key into the log call — Hermes
# has a known bug class where credential-shaped literals get corrupted in logs.
forensic: Dict[str, Any] = {
"reason": reason,
"error_code": error.code,
# No session_id field exists on Nous state; provenance is client_id +
# agent_key_id (both non-secret routing identifiers).
"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["auth_json_size"] = st.st_size
forensic["auth_json_mtime"] = st.st_mtime
forensic["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)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
already_expired = parsed < datetime.now(timezone.utc)
except ValueError:
already_expired = None
forensic["token_already_expired"] = already_expired
logger.warning(
"Nous OAuth state quarantined (terminal auth death): %s",
json.dumps(forensic, 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
pool = auth_store["credential_pool"]
retained = []
removed = False
singleton_sources = {NOUS_DEVICE_CODE_SOURCE, f"manual:{NOUS_DEVICE_CODE_SOURCE}"}
for entry in entries:
if isinstance(entry, dict) and entry.get("source") in singleton_sources:
removed = True
continue
retained.append(entry)
if removed:
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]]:
"""Attempt to rehydrate Nous OAuth state from the shared store.
Runs a forced refresh with the stored refresh_token to mint a fresh inference JWT scoped to
this profile and returns the auth_state dict ready for ``persist_nous_credentials()``.
Returns ``None`` on any failure (expired token, portal unreachable) so the caller falls
through to the normal device-code flow.
"""
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=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
shared = _read_shared_nous_state()
if not shared:
return None
# Build a full state dict so refresh_nous_oauth_from_state has every
# field it needs. force_refresh=True gets us a fresh access_token
# for this profile.
state: Dict[str, Any] = {
**_nous_shared_shape(shared),
"agent_key": None,
"agent_key_expires_at": None,
"tls": {"insecure": False, "ca_bundle": None},
}
def _persist_shared_refresh(updated_state: Dict[str, Any], _reason: str) -> None:
_write_shared_nous_state(updated_state)
refreshed = refresh_nous_oauth_from_state(
state,
timeout_seconds=timeout_seconds,
force_refresh=True,
on_state_update=_persist_shared_refresh,
)
_write_shared_nous_state(refreshed)
except AuthError as exc:
_oauth_trace(
"nous_shared_import_failed",
error_type=type(exc).__name__,
error_code=getattr(exc, "code", None),
)
if _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
except Exception as exc:
_oauth_trace(
"nous_shared_import_failed",
error_type=type(exc).__name__,
)
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
try:
error_payload = response.json()
except Exception as exc:
raise _nous_err("Refresh token exchange failed", relogin=True) from exc
code = str(error_payload.get("error", "invalid_grant"))
description = str(error_payload.get("error_description") or "Refresh token exchange failed")
relogin = code in {"invalid_grant", "invalid_token", "refresh_token_reused"}
# Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal
# server and surface an actionable message. This fires when an external
# process (health-check script, monitoring tool, custom self-heal hook)
# called POST /api/oauth/token with Hermes's refresh_token without
# persisting the rotated token back to auth.json — the server then
# retires the original RT, Hermes's next refresh uses it, and the whole
# session chain gets revoked as a token-theft signal (#15099).
lowered = description.lower()
if code == "refresh_token_reused" or "reuse" in lowered or "reuse detected" in lowered:
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 Nous refresh token; on a 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.
When the Portal-returned URL is rejected by the allowlist (returns None), reset 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 keeps re-validating to None on every refresh
and silently re-uses the dead endpoint.
"""
return (
_validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
or DEFAULT_NOUS_INFERENCE_URL
)
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
timeout = httpx.Timeout(timeout_seconds)
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=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")
payload = response.json()
data = payload.get("data")
if not isinstance(data, list):
return []
model_ids: List[str] = []
for item in data:
if not isinstance(item, dict):
continue
model_id = item.get("id")
if _nonempty_str(model_id):
mid = model_id.strip()
# Skip Hermes models — they're not reliable for agentic tool-calling
if "hermes" in mid.lower():
continue
model_ids.append(mid)
# Sort: prefer opus > pro > haiku/flash > sonnet (sonnet is cheap/fast,
# users who want the best model should see opus first).
def _model_priority(mid: str) -> tuple:
low = mid.lower()
if "opus" in low:
return (0, mid)
if "pro" in low and "sonnet" not in low:
return (1, mid)
if "sonnet" in low:
return (3, mid)
return (2, mid)
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")
if not _nonempty_str(key):
return False
return _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`` is called after a successful access-token refresh. Callers that own
persistent state can use it to save the newly rotated refresh token before later validation can
fail.
"""
from hermes_cli.auth import _assert_nous_inference_jwt_usable, _nous_invoke_jwt_status, _refresh_access_token, _resolve_verify, _select_nous_invoke_jwt
state: Dict[str, Any] = {
"access_token": access_token,
"refresh_token": refresh_token,
"client_id": client_id or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": (portal_base_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/"),
"inference_base_url": (inference_base_url or DEFAULT_NOUS_INFERENCE_URL).rstrip("/"),
"token_type": token_type or "Bearer",
"scope": scope or DEFAULT_NOUS_SCOPE,
"obtained_at": obtained_at,
"expires_at": expires_at,
"agent_key": agent_key,
"agent_key_expires_at": agent_key_expires_at,
"tls": {
"insecure": bool(insecure),
"ca_bundle": ca_bundle,
},
}
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
current_invoke_jwt_status = _nous_invoke_jwt_status(
state.get("access_token"),
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
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 _nous_err(
"Nous Portal access token is not a usable inference JWT "
f"({current_invoke_jwt_status}) and no refresh token is available. "
"Re-authenticate with: hermes auth add nous",
current_invoke_jwt_status, relogin=True,
)
raise _nous_err(
"No refresh token is available for Nous Portal.",
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 refresh_nous_oauth_from_state(
state: 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. Thin wrapper around refresh_nous_oauth_pure."""
tls = state.get("tls") or {}
return refresh_nous_oauth_pure(
state.get("access_token", ""),
state.get("refresh_token", ""),
state.get("client_id", "hermes-cli"),
state.get("portal_base_url", DEFAULT_NOUS_PORTAL_URL),
state.get("inference_base_url", DEFAULT_NOUS_INFERENCE_URL),
token_type=state.get("token_type", "Bearer"),
scope=state.get("scope", DEFAULT_NOUS_SCOPE),
obtained_at=state.get("obtained_at"),
expires_at=state.get("expires_at"),
agent_key=state.get("agent_key"),
agent_key_expires_at=state.get("agent_key_expires_at"),
timeout_seconds=timeout_seconds,
insecure=tls.get("insecure"),
ca_bundle=tls.get("ca_bundle"),
force_refresh=force_refresh,
on_state_update=on_state_update,
)
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 two places: ``providers.nous`` (401 recovery, pool seeding) and
``credential_pool.nous`` (runtime ``pool.select()``). Writing only a pool entry left the
singleton empty and made expiry recovery fail silently, so this writes the singleton and then
``load_pool("nous")`` upserts the canonical ``device_code`` entry in place (never duplicates).
``label`` is embedded in the singleton so re-seeding keeps the user's display name.
"""
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)
# Mirror to the shared store so a new profile can one-tap import
# these credentials via `hermes auth add nous --type oauth`. Best-
# effort: any I/O failure is logged and swallowed (the per-profile
# auth.json is still the source of truth).
_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)
class _NousStatePersister:
"""Writes Nous provider state to its source store, skipping no-op writes.
Writes where only derived TTL countdowns changed are skipped; this keeps the mtime-keyed Nous
auth-status cache warm during read paths. Every real write is mirrored to the shared store so
sibling profiles don't hold stale refresh_tokens after rotation (best-effort — failures are
logged and swallowed inside ``_write_shared_nous_state``).
"""
def __init__(
self,
auth_store: Dict[str, Any],
state: Dict[str, Any],
state_source_path: Optional[Path],
sequence_id: str,
) -> None:
self._auth_store = auth_store
self._state = state
self._source_path = state_source_path
self._sequence_id = sequence_id
self._persisted_state = dict(state)
self.persisted_any = False
def persist(self, reason: str) -> None:
from hermes_cli.auth import _save_provider_state_to_source, _write_shared_nous_state
state = self._state
if (
_nous_effective_provider_state(state)
== _nous_effective_provider_state(self._persisted_state)
):
_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 _nous_effective_routing(state: Dict[str, Any]) -> tuple[str, str, str, str]:
"""Resolve every routing value that shared OAuth state can replace.
Returns ``(portal_url, stored_inference_url, effective_inference_url, client_id)``. 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 must never be persisted.
"""
from hermes_cli.auth import _NOUS_PORTAL_ALLOWED_HOSTS, _optional_base_url
portal_url = (
_optional_base_url(state.get("portal_base_url"))
or os.getenv("HERMES_PORTAL_BASE_URL")
or os.getenv("NOUS_PORTAL_BASE_URL")
or DEFAULT_NOUS_PORTAL_URL
).rstrip("/")
# A persisted/stale portal_base_url is where the refresh token gets
# POSTed on refresh — 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 = parsed_portal_url.hostname
loopback_http = (
parsed_portal_url.scheme == "http"
and portal_host in {"localhost", "127.0.0.1"}
)
trusted_scheme = parsed_portal_url.scheme == "https" or loopback_http
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
stored_inference_url = (
_validate_nous_inference_url_from_network(
_optional_base_url(state.get("inference_base_url"))
)
or 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),
)
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.
Ensures access_token is a valid inference-scoped JWT, refreshing it when
needed. Concurrent processes coordinate through the auth store file lock.
``stale_access_token`` is the bearer that just failed upstream (401). When
set together with ``force_refresh``, the refresh POST is skipped if the
store — re-read under the lock — already holds a *different*, usable
token: another process won the rotation, so this caller adopts it instead
of rotating the shared grant again (otherwise N concurrent processes at the
same expiry issue N refreshes, each invalidating a sibling's fresh token).
"""
from hermes_cli.auth import _assert_nous_inference_jwt_usable, _auth_file_path, _coerce_ttl_seconds, _nous_invoke_jwt_status, _parse_iso_timestamp, _provider_state_transaction, _resolve_verify, _select_nous_invoke_jwt, _sync_nous_pool_from_auth_store, _tls_state_from_verify
sequence_id = uuid.uuid4().hex[:12]
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.", relogin=True)
def _already_rotated_by_peer(token: Any) -> bool:
return bool(
force_refresh
and stale_access_token
and isinstance(token, str)
and token
and token != stale_access_token
and _nous_invoke_jwt_status(
token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
) is None
)
persister = _NousStatePersister(auth_store, state, state_source_path, sequence_id)
_persist_state = persister.persist
(
portal_base_url,
stored_inference_base_url,
inference_base_url,
client_id,
) = _nous_effective_routing(state)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
_oauth_trace(
"nous_runtime_credentials_start",
sequence_id=sequence_id,
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
)
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
if not isinstance(access_token, str) or not access_token:
with _nous_shared_store_lock(
timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)
):
if _merge_shared_nous_oauth_state(state):
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
(
portal_base_url,
stored_inference_base_url,
inference_base_url,
client_id,
) = _nous_effective_routing(state)
_persist_state("runtime_shared_merge_missing_access_token")
if not isinstance(access_token, str) or not access_token:
raise _nous_err(
"No access token found for Nous Portal login.",
relogin=True,
)
invoke_jwt_status = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
# Under the store lock: if the bearer that failed upstream is no
# longer the one on disk and the on-disk one is usable, a peer
# already rotated — adopt, never re-POST the shared grant.
if _already_rotated_by_peer(access_token):
_oauth_trace(
"refresh_skipped_peer_rotated",
sequence_id=sequence_id,
access_token_fp=_token_fingerprint(access_token),
)
force_refresh = False
if force_refresh or invoke_jwt_status is not None:
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
if _merge_shared_nous_oauth_state(state):
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
(
portal_base_url,
stored_inference_base_url,
inference_base_url,
client_id,
) = _nous_effective_routing(state)
invoke_jwt_status = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
_persist_state("post_shared_merge_access_unusable")
if _already_rotated_by_peer(access_token):
_oauth_trace(
"refresh_skipped_peer_rotated",
sequence_id=sequence_id,
access_token_fp=_token_fingerprint(access_token),
)
force_refresh = False
if force_refresh or invoke_jwt_status is not None:
if not isinstance(refresh_token, str) or not refresh_token:
reason = invoke_jwt_status or "force_refresh"
raise _nous_err(
"Nous Portal access token is not a usable inference JWT "
f"({reason}) and no refresh token is available. "
"Re-authenticate with: hermes auth add nous",
reason, relogin=True,
)
refresh_reason = "force_refresh" if force_refresh else (invoke_jwt_status or "access_unusable")
_oauth_trace(
"refresh_start",
sequence_id=sequence_id,
reason=refresh_reason,
refresh_token_fp=_token_fingerprint(refresh_token),
)
refreshed = _refresh_nous_or_quarantine(
client=client,
auth_store=auth_store,
state=state,
portal_base_url=portal_base_url,
client_id=client_id,
refresh_token=refresh_token,
reason="runtime_access_refresh_failure",
persist=lambda: _persist_state("terminal_runtime_access_refresh_failure"),
)
previous_refresh_token = refresh_token
# The validated, network-provenance URL is what gets persisted to
# auth.json (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 — it is never persisted.
stored_inference_base_url = _healed_nous_inference_url(refreshed)
inference_base_url = (
_nous_inference_env_override() or stored_inference_base_url
)
_apply_nous_refreshed_tokens(
state, refreshed, refresh_token,
inference_base_url=stored_inference_base_url,
)
access_token = state["access_token"]
refresh_token = state["refresh_token"]
_oauth_trace(
"refresh_success",
sequence_id=sequence_id,
reason=refresh_reason,
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
new_refresh_token_fp=_token_fingerprint(refresh_token),
)
# Persist immediately so validation failures cannot drop rotated refresh tokens.
_persist_state("post_refresh_access_token")
_assert_nous_inference_jwt_usable(
state,
access_token=access_token,
)
_select_nous_invoke_jwt(
state,
access_token=access_token,
sequence_id=sequence_id,
)
# Persist routing and TLS metadata for non-interactive refresh.
# Persist the validated, network-provenance URL — NEVER the env
# override (which is a runtime-only overlay; persisting it would
# leak a dev/staging host into auth.json and survive unsetting it).
state["portal_base_url"] = portal_base_url
state["inference_base_url"] = stored_inference_base_url
state["client_id"] = client_id
state["tls"] = _tls_state_from_verify(verify)
_persist_state("resolve_nous_runtime_credentials_final")
if persister.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")
expires_epoch = _parse_iso_timestamp(expires_at)
expires_in = (
max(0, int(expires_epoch - time.time()))
if expires_epoch is not None
else _coerce_ttl_seconds(state.get("agent_key_expires_in"))
)
return {
"provider": "nous",
"base_url": inference_base_url,
"api_key": api_key,
"key_id": state.get("agent_key_id"),
"expires_at": expires_at,
"expires_in": expires_in,
"source": NOUS_AUTH_PATH_INVOKE_JWT,
# Preserve the public semantic source label while exposing the concrete
# store 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.
This is a 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")
if not pool or not pool.has_credentials():
return _empty_nous_auth_status()
entries = list(pool.entries())
if not entries:
return _empty_nous_auth_status()
def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
agent_exp = _parse_iso_timestamp(getattr(entry, "agent_key_expires_at", None)) or 0.0
access_exp = _parse_iso_timestamp(getattr(entry, "expires_at", None)) or 0.0
priority = int(getattr(entry, "priority", 0) or 0)
return (agent_exp, access_exp, -priority)
entry = max(entries, key=_entry_sort_key)
runtime_key = getattr(entry, "runtime_api_key", None)
if not runtime_key:
return _empty_nous_auth_status()
access_token = getattr(entry, "access_token", None)
auth_type = str(getattr(entry, "auth_type", "") or "").strip().lower()
refresh_token = getattr(entry, "refresh_token", None)
is_portal_oauth = bool(access_token) and (
auth_type.startswith("oauth") or bool(refresh_token)
)
label = getattr(entry, "label", "unknown")
portal_status_url = None
if is_portal_oauth:
portal_status_url = (
getattr(entry, "portal_base_url", None)
or DEFAULT_NOUS_PORTAL_URL
)
return {
"logged_in": is_portal_oauth,
"portal_base_url": portal_status_url,
"inference_base_url": getattr(entry, "inference_base_url", None)
or getattr(entry, "runtime_base_url", None)
or getattr(entry, "base_url", None),
"access_token": access_token if is_portal_oauth else None,
"access_expires_at": getattr(entry, "expires_at", None),
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
"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)."""
access_token = state.get("access_token")
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,
}
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 state:
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"),
}
)
return base_status
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
return _snapshot_nous_pool_status()
def get_nous_auth_status_local() -> Dict[str, Any]:
"""Refresh-free Nous auth snapshot for read-only display surfaces.
Unlike :func:`get_nous_auth_status`, this NEVER calls ``resolve_nous_runtime_credentials()`` and
therefore never performs an OAuth refresh POST or consumes a single-use refresh token. It
reports the persisted auth-store state, classifying the access token with a local invoke-JWT
decode only.
``logged_in`` here means "a persisted login exists that the runtime can use or refresh": a
currently-usable invoke JWT, or a refresh token that has not been terminally quarantined. It
does not prove the refresh token is still accepted server-side — only a live resolve can do
that.
"""
from hermes_cli.auth import _nous_invoke_jwt_status, get_provider_auth_state
try:
state = get_provider_auth_state("nous")
except Exception:
state = None
if not state:
return _snapshot_nous_pool_status()
access_token = state.get("access_token")
jwt_reason = _nous_invoke_jwt_status(
access_token,
scope=state.get("scope"),
expires_at=state.get("expires_at"),
)
last_err = state.get("last_auth_error")
terminal = bool(
isinstance(last_err, dict)
and last_err.get("relogin_required")
and not (access_token or state.get("refresh_token"))
)
logged_in = (jwt_reason is None) or (
bool(state.get("refresh_token")) and not terminal
)
status = _nous_status_from_state(state, logged_in=logged_in, source="auth_store_local")
if terminal and isinstance(last_err, dict):
status["relogin_required"] = True
status["error_code"] = last_err.get("code")
status["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 with
# a permissive schema, so new members are non-breaking but should stay 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.
Determinable with NO working token — it reads local auth-store state only, which is exactly the
condition a dead hosted box is in. This function is called by the frequently-polled public
``/api/status`` endpoint, so it must never resolve credentials or perform an OAuth refresh.
ANTI-FLAP CONTRACT: only a *terminal* failure maps to "terminal". A normal mid-rotation blip, a
transient network error, or a merely-expiring token must NOT report "terminal" (that would
trigger a spurious NAS re-mint on a healthy box).
"""
from hermes_cli.auth import _nous_invoke_jwt_status, get_provider_auth_state
# A persisted quarantine marker is the strongest, most stable terminal
# signal: the refresh path writes `last_auth_error.relogin_required=True`
# into the Nous provider state when it clears dead tokens (the exact path
# that produced the incident's "No access token found"). Read it directly
# so we report "terminal" even after the in-memory AuthError is long gone.
try:
state = get_provider_auth_state("nous")
except Exception:
return NOUS_SESSION_UNKNOWN
if not state:
return NOUS_SESSION_UNKNOWN
last_err = state.get("last_auth_error")
# Only terminal while there is no usable credential left. If a later
# successful login repopulated tokens, the stale marker must not
# keep reporting terminal.
if (
isinstance(last_err, dict)
and last_err.get("relogin_required")
and not (state.get("access_token") or state.get("refresh_token"))
):
return NOUS_SESSION_TERMINAL
if _nous_invoke_jwt_status(
state.get("access_token"),
scope=state.get("scope"),
expires_at=state.get("expires_at"),
) is None:
return NOUS_SESSION_VALID
# Missing, malformed, expired, or merely expiring credentials are not proof
# of a terminal session. Runtime inference/keepalive paths own refreshes;
# the health endpoint remains side-effect free and reports indeterminate.
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).
Checks the credential pool first (where `hermes auth` / `hermes model` store device_code
tokens), optionally consults *on_pool_miss* for a pool-derived degraded status, then falls
back to the legacy provider state via *resolve*.
"""
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.select()
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,
}
if on_pool_miss is not None:
degraded = on_pool_miss()
if degraded:
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"),
}
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
timeout = httpx.Timeout(timeout_seconds)
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 httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=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="—",
)
# Surface the verification URL/code to an out-of-band consumer (e.g. the
# TUI gateway, whose stdout is a JSON-RPC pipe — a plain print() there is
# dropped). Fired AFTER the print/browser block and BEFORE polling blocks,
# so the consumer can render the link while we wait. Best-effort.
if on_verification is not None:
try:
on_verification(verification_url, user_code)
except Exception:
pass
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))
expires_at = now.timestamp() + token_expires_in
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": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
"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("/")
message = format_auth_error(exc)
print()
print(message)
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
try:
_write_shared_nous_state(auth_state)
except Exception:
pass
try:
_sync_nous_pool_from_auth_store()
except Exception:
pass
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`` and persist the result.
Lazy step-up triggered by ``403 insufficient_scope``. The user must be ADMIN/OWNER and select
"Allow Remote Spending" in the portal, otherwise the server silently downscopes and this returns
False. Reuses the held credential's portal/inference URLs + client_id so the step-up targets the
same deployment, and persists like ``_login_nous`` but WITHOUT 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"]
# Build the step-up scope: existing scopes (if any) + billing:manage, deduped,
# order-stable. Fall back to the standard inference+tool+billing set.
_raw_scope = prior.get("scope")
prior_scope = _raw_scope if isinstance(_raw_scope, str) else ""
requested: list[str] = []
for tok in (prior_scope.split() or [NOUS_INFERENCE_INVOKE_SCOPE, "tool:invoke"]):
if tok and tok not in requested:
requested.append(tok)
if NOUS_BILLING_MANAGE_SCOPE not in requested:
requested.append(NOUS_BILLING_MANAGE_SCOPE)
scope = " ".join(requested)
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=scope,
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, get_pricing_for_provider,
check_nous_free_tier, partition_nous_models_by_tier,
nous_policy_allowed_ids, restrict_to_nous_policy,
union_with_portal_free_recommendations,
union_with_portal_paid_recommendations,
)
model_ids = get_curated_nous_model_ids()
print()
unavailable_models: list = []
unavailable_message = ""
if model_ids:
pricing = get_pricing_for_provider("nous")
# Force fresh account data for model selection so recent credit
# purchases are reflected immediately.
free_tier = check_nous_free_tier(force_fresh=True)
_portal_for_recs = auth_state.get("portal_base_url", "")
# Narrow before the tier split, so a rescued id still has to
# pass the free/paid predicate.
_policy_allowed = nous_policy_allowed_ids()
_policy_narrowed = False
if free_tier:
try:
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)
unavailable_message = (
format_nous_portal_entitlement_message(
_account_info,
capability="paid Nous models",
)
or ""
)
except Exception:
unavailable_message = ""
# The Portal's free/paidRecommendedModels endpoint is the source of
# truth for what's available *right now*. Augment the curated list with
# anything new the Portal flags so users on older Hermes builds still
# see newly-launched models 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_for_recs)
_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,
)
_portal = auth_state.get("portal_base_url", "")
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,
)
elif 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
shared = _read_shared_nous_state()
if not shared:
return None
try:
shared_path = _nous_shared_store_path()
except RuntimeError:
shared_path = None
print()
if shared_path:
print(f"Found existing Nous OAuth credentials at {shared_path}")
else:
print("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 _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_auth_store, _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
insecure = bool(getattr(args, "insecure", False))
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=insecure,
ca_bundle=ca_bundle,
)
inference_base_url = auth_state["inference_base_url"]
# Snapshot the prior active_provider BEFORE _save_provider_state
# overwrites it to "nous". If the user picks "Skip (keep current)"
# during model selection below, we restore this so the user's previous
# provider (e.g. openrouter) is preserved.
with _auth_store_lock():
_prior_store = _load_auth_store()
prior_active_provider = _prior_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
# these credentials. Best-effort: any I/O failure is logged and
# swallowed inside the helper.
_write_shared_nous_state(auth_state)
_sync_nous_pool_from_auth_store()
print()
print("Login successful!")
print(f" Auth state: {saved_to}")
# Resolve model BEFORE writing provider to config.yaml so we never
# leave the config in a half-updated state (provider=nous but model
# still set to the previous provider's model, e.g. opus from
# OpenRouter). The auth.json active_provider was already set above.
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}")
# Write provider + model atomically so config is never mismatched.
# If no model was selected (user picked "Skip (keep current)",
# model list fetch failed, or no curated models were available),
# preserve the user's previous provider — don't silently switch
# them to Nous with a mismatched model. The Nous OAuth tokens
# stay saved for future use.
if not selected_model:
# Restore the prior active_provider that _save_provider_state
# overwrote to "nous". config.yaml model.provider is left
# untouched, so the user's previous provider is fully preserved.
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)
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,
)
if 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:
print(f"Login failed: {exc}")
raise SystemExit(1)