2151 lines
85 KiB
Python
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)
|