mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-04 12:33:08 +00:00
fix(auth) fix a few cases where refresh tokens were not rotated.
This commit is contained in:
parent
20bffa5b37
commit
569bc94b59
6 changed files with 166 additions and 109 deletions
|
|
@ -623,18 +623,35 @@ class CredentialPool:
|
||||||
return entry
|
return entry
|
||||||
store_refresh = state.get("refresh_token", "")
|
store_refresh = state.get("refresh_token", "")
|
||||||
store_access = state.get("access_token", "")
|
store_access = state.get("access_token", "")
|
||||||
if store_refresh and store_refresh != entry.refresh_token:
|
comparable_updates = {
|
||||||
|
"access_token": store_access,
|
||||||
|
"refresh_token": store_refresh,
|
||||||
|
"expires_at": state.get("expires_at"),
|
||||||
|
"agent_key": state.get("agent_key"),
|
||||||
|
"agent_key_expires_at": state.get("agent_key_expires_at"),
|
||||||
|
"inference_base_url": state.get("inference_base_url"),
|
||||||
|
}
|
||||||
|
should_sync = any(
|
||||||
|
value not in (None, "") and getattr(entry, key, None) != value
|
||||||
|
for key, value in comparable_updates.items()
|
||||||
|
)
|
||||||
|
if should_sync:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Pool entry %s: syncing tokens from auth.json (Nous refresh token changed)",
|
"Pool entry %s: syncing Nous state from auth.json",
|
||||||
entry.id,
|
entry.id,
|
||||||
)
|
)
|
||||||
field_updates: Dict[str, Any] = {
|
field_updates: Dict[str, Any] = {
|
||||||
"access_token": store_access,
|
|
||||||
"refresh_token": store_refresh,
|
|
||||||
"last_status": None,
|
"last_status": None,
|
||||||
"last_status_at": None,
|
"last_status_at": None,
|
||||||
"last_error_code": None,
|
"last_error_code": None,
|
||||||
|
"last_error_reason": None,
|
||||||
|
"last_error_message": None,
|
||||||
|
"last_error_reset_at": None,
|
||||||
}
|
}
|
||||||
|
if store_access:
|
||||||
|
field_updates["access_token"] = store_access
|
||||||
|
if store_refresh:
|
||||||
|
field_updates["refresh_token"] = store_refresh
|
||||||
if state.get("expires_at"):
|
if state.get("expires_at"):
|
||||||
field_updates["expires_at"] = state["expires_at"]
|
field_updates["expires_at"] = state["expires_at"]
|
||||||
if state.get("agent_key"):
|
if state.get("agent_key"):
|
||||||
|
|
@ -813,40 +830,15 @@ class CredentialPool:
|
||||||
synced = self._sync_nous_entry_from_auth_store(entry)
|
synced = self._sync_nous_entry_from_auth_store(entry)
|
||||||
if synced is not entry:
|
if synced is not entry:
|
||||||
entry = synced
|
entry = synced
|
||||||
nous_state = {
|
auth_mod.resolve_nous_runtime_credentials(
|
||||||
"access_token": entry.access_token,
|
|
||||||
"refresh_token": entry.refresh_token,
|
|
||||||
"client_id": entry.client_id,
|
|
||||||
"portal_base_url": entry.portal_base_url,
|
|
||||||
"inference_base_url": entry.inference_base_url,
|
|
||||||
"token_type": entry.token_type,
|
|
||||||
"scope": entry.scope,
|
|
||||||
"obtained_at": entry.obtained_at,
|
|
||||||
"expires_at": entry.expires_at,
|
|
||||||
"agent_key": entry.agent_key,
|
|
||||||
"agent_key_expires_at": entry.agent_key_expires_at,
|
|
||||||
"tls": entry.tls,
|
|
||||||
}
|
|
||||||
refreshed = auth_mod.refresh_nous_oauth_from_state(
|
|
||||||
nous_state,
|
|
||||||
min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||||
force_refresh=force,
|
|
||||||
inference_auth_mode=(
|
inference_auth_mode=(
|
||||||
auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY
|
auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY
|
||||||
if force
|
if force
|
||||||
else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO
|
else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# Apply returned fields: dataclass fields via replace, extras via dict update
|
updated = self._sync_nous_entry_from_auth_store(entry)
|
||||||
field_updates = {}
|
|
||||||
extra_updates = dict(entry.extra)
|
|
||||||
_field_names = {f.name for f in fields(entry)}
|
|
||||||
for k, v in refreshed.items():
|
|
||||||
if k in _field_names:
|
|
||||||
field_updates[k] = v
|
|
||||||
elif k in _EXTRA_KEYS:
|
|
||||||
extra_updates[k] = v
|
|
||||||
updated = replace(entry, extra=extra_updates, **field_updates)
|
|
||||||
else:
|
else:
|
||||||
return entry
|
return entry
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -89,11 +89,6 @@ NOUS_INFERENCE_AUTH_MODES = frozenset({
|
||||||
NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt"
|
NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt"
|
||||||
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE = "legacy_session_key_cache"
|
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE = "legacy_session_key_cache"
|
||||||
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT = "legacy_session_key_mint"
|
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT = "legacy_session_key_mint"
|
||||||
NOUS_AUTH_PATHS = frozenset({
|
|
||||||
NOUS_AUTH_PATH_INVOKE_JWT,
|
|
||||||
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE,
|
|
||||||
NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT,
|
|
||||||
})
|
|
||||||
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes
|
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes
|
||||||
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
|
ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry
|
||||||
NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
||||||
|
|
@ -3991,7 +3986,7 @@ def _is_terminal_nous_refresh_error(exc: Exception) -> bool:
|
||||||
return (
|
return (
|
||||||
isinstance(exc, AuthError)
|
isinstance(exc, AuthError)
|
||||||
and exc.provider == "nous"
|
and exc.provider == "nous"
|
||||||
and exc.code in {"invalid_grant", "invalid_token"}
|
and exc.code in {"invalid_grant", "invalid_token", "refresh_token_reused"}
|
||||||
and bool(exc.relogin_required)
|
and bool(exc.relogin_required)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -4103,12 +4098,16 @@ def _try_import_shared_nous_state(
|
||||||
"tls": {"insecure": False, "ca_bundle": 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(
|
refreshed = refresh_nous_oauth_from_state(
|
||||||
state,
|
state,
|
||||||
min_key_ttl_seconds=min_key_ttl_seconds,
|
min_key_ttl_seconds=min_key_ttl_seconds,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
force_refresh=True,
|
force_refresh=True,
|
||||||
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH,
|
inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH,
|
||||||
|
on_state_update=_persist_shared_refresh,
|
||||||
)
|
)
|
||||||
_write_shared_nous_state(refreshed)
|
_write_shared_nous_state(refreshed)
|
||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
|
|
@ -4163,7 +4162,7 @@ def _refresh_access_token(
|
||||||
|
|
||||||
code = str(error_payload.get("error", "invalid_grant"))
|
code = str(error_payload.get("error", "invalid_grant"))
|
||||||
description = str(error_payload.get("error_description") or "Refresh token exchange failed")
|
description = str(error_payload.get("error_description") or "Refresh token exchange failed")
|
||||||
relogin = code in {"invalid_grant", "invalid_token"}
|
relogin = code in {"invalid_grant", "invalid_token", "refresh_token_reused"}
|
||||||
|
|
||||||
# Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal
|
# Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal
|
||||||
# server and surface an actionable message. This fires when an external
|
# server and surface an actionable message. This fires when an external
|
||||||
|
|
@ -4173,7 +4172,7 @@ def _refresh_access_token(
|
||||||
# retires the original RT, Hermes's next refresh uses it, and the whole
|
# retires the original RT, Hermes's next refresh uses it, and the whole
|
||||||
# session chain gets revoked as a token-theft signal (#15099).
|
# session chain gets revoked as a token-theft signal (#15099).
|
||||||
lowered = description.lower()
|
lowered = description.lower()
|
||||||
if "reuse" in lowered or "reuse detected" in lowered:
|
if code == "refresh_token_reused" or "reuse" in lowered or "reuse detected" in lowered:
|
||||||
description = (
|
description = (
|
||||||
"Nous Portal detected refresh-token reuse and revoked this session.\n"
|
"Nous Portal detected refresh-token reuse and revoked this session.\n"
|
||||||
"This usually means an external process (monitoring script, "
|
"This usually means an external process (monitoring script, "
|
||||||
|
|
@ -4185,6 +4184,7 @@ def _refresh_access_token(
|
||||||
"instead.\n"
|
"instead.\n"
|
||||||
"Re-authenticate with: hermes auth add nous"
|
"Re-authenticate with: hermes auth add nous"
|
||||||
)
|
)
|
||||||
|
relogin = True
|
||||||
|
|
||||||
raise AuthError(description, provider="nous", code=code, relogin_required=relogin)
|
raise AuthError(description, provider="nous", code=code, relogin_required=relogin)
|
||||||
|
|
||||||
|
|
@ -4418,8 +4418,14 @@ def refresh_nous_oauth_pure(
|
||||||
ca_bundle: Optional[str] = None,
|
ca_bundle: Optional[str] = None,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
|
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||||
|
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Refresh Nous OAuth state without mutating auth.json."""
|
"""Refresh Nous OAuth state without mutating auth.json directly.
|
||||||
|
|
||||||
|
``on_state_update`` is called after a successful access-token refresh and
|
||||||
|
before any subsequent agent-key mint. Callers that own persistent state can
|
||||||
|
use it to save the newly rotated refresh token before later work can fail.
|
||||||
|
"""
|
||||||
inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode)
|
inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode)
|
||||||
state: Dict[str, Any] = {
|
state: Dict[str, Any] = {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
|
|
@ -4479,6 +4485,8 @@ def refresh_nous_oauth_pure(
|
||||||
state["expires_at"] = datetime.fromtimestamp(
|
state["expires_at"] = datetime.fromtimestamp(
|
||||||
now.timestamp() + access_ttl, tz=timezone.utc
|
now.timestamp() + access_ttl, tz=timezone.utc
|
||||||
).isoformat()
|
).isoformat()
|
||||||
|
if on_state_update is not None:
|
||||||
|
on_state_update(dict(state), "post_refresh_access_token")
|
||||||
|
|
||||||
selected_auth_path, fallback_reason = _choose_nous_inference_auth_path(
|
selected_auth_path, fallback_reason = _choose_nous_inference_auth_path(
|
||||||
state,
|
state,
|
||||||
|
|
@ -4519,6 +4527,7 @@ def refresh_nous_oauth_from_state(
|
||||||
timeout_seconds: float = 15.0,
|
timeout_seconds: float = 15.0,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
|
inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||||
|
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure."""
|
"""Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure."""
|
||||||
tls = state.get("tls") or {}
|
tls = state.get("tls") or {}
|
||||||
|
|
@ -4540,6 +4549,7 @@ def refresh_nous_oauth_from_state(
|
||||||
ca_bundle=tls.get("ca_bundle"),
|
ca_bundle=tls.get("ca_bundle"),
|
||||||
force_refresh=force_refresh,
|
force_refresh=force_refresh,
|
||||||
inference_auth_mode=inference_auth_mode,
|
inference_auth_mode=inference_auth_mode,
|
||||||
|
on_state_update=on_state_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4603,6 +4613,7 @@ def persist_nous_credentials(
|
||||||
|
|
||||||
|
|
||||||
def _sync_nous_pool_from_auth_store() -> None:
|
def _sync_nous_pool_from_auth_store() -> None:
|
||||||
|
"""Best-effort pool reseed after providers.nous changes; never fail login."""
|
||||||
try:
|
try:
|
||||||
from agent.credential_pool import load_pool
|
from agent.credential_pool import load_pool
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
"""Nous Portal upstream adapter.
|
"""Nous Portal upstream adapter.
|
||||||
|
|
||||||
Reads the user's Nous OAuth state from ``~/.hermes/auth.json``, refreshes
|
Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the
|
||||||
the access token and resolves the ``agent_key`` compatibility credential
|
shared runtime resolver, refreshes the access token and resolves the
|
||||||
when needed, then exposes the upstream base URL plus bearer for the proxy
|
``agent_key`` compatibility credential when needed, then exposes the upstream
|
||||||
server to forward to.
|
base URL plus bearer for the proxy server to forward to.
|
||||||
|
|
||||||
The ``agent_key`` field may hold either a NAS invoke JWT or the legacy
|
The ``agent_key`` field may hold either a NAS invoke JWT or the legacy
|
||||||
opaque session key. The refresh helper handles both — see
|
opaque session key. The refresh helper handles both — see
|
||||||
:func:`hermes_cli.auth.refresh_nous_oauth_from_state`.
|
:func:`hermes_cli.auth.resolve_nous_runtime_credentials`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -22,12 +22,13 @@ from hermes_cli.auth import (
|
||||||
NOUS_INFERENCE_AUTH_MODE_AUTO,
|
NOUS_INFERENCE_AUTH_MODE_AUTO,
|
||||||
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
NOUS_INFERENCE_AUTH_MODE_LEGACY,
|
||||||
_load_auth_store,
|
_load_auth_store,
|
||||||
|
_auth_store_lock,
|
||||||
_is_terminal_nous_refresh_error,
|
_is_terminal_nous_refresh_error,
|
||||||
_quarantine_nous_oauth_state,
|
_quarantine_nous_oauth_state,
|
||||||
_quarantine_nous_pool_entries,
|
_quarantine_nous_pool_entries,
|
||||||
_save_auth_store,
|
_save_auth_store,
|
||||||
_write_shared_nous_state,
|
_write_shared_nous_state,
|
||||||
refresh_nous_oauth_from_state,
|
resolve_nous_runtime_credentials,
|
||||||
)
|
)
|
||||||
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
|
from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential
|
||||||
|
|
||||||
|
|
@ -50,9 +51,8 @@ class NousPortalAdapter(UpstreamAdapter):
|
||||||
"""Proxy upstream for the Nous Portal inference API."""
|
"""Proxy upstream for the Nous Portal inference API."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# Lock guards _load → refresh → _save against parallel proxy requests
|
# Serialize proxy requests in this process; cross-process token refresh
|
||||||
# racing to refresh expired tokens. Refresh itself is HTTP, so we
|
# and persistence are handled by resolve_nous_runtime_credentials().
|
||||||
# hold the lock across the network call (brief; OAuth refresh is fast).
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -107,8 +107,7 @@ class NousPortalAdapter(UpstreamAdapter):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
refreshed = refresh_nous_oauth_from_state(
|
refreshed = resolve_nous_runtime_credentials(
|
||||||
state,
|
|
||||||
inference_auth_mode=inference_auth_mode,
|
inference_auth_mode=inference_auth_mode,
|
||||||
)
|
)
|
||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
|
|
@ -131,22 +130,20 @@ class NousPortalAdapter(UpstreamAdapter):
|
||||||
f"Failed to refresh Nous Portal credentials: {exc}"
|
f"Failed to refresh Nous Portal credentials: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
self._save_state(refreshed)
|
agent_key = refreshed.get("api_key")
|
||||||
|
|
||||||
agent_key = refreshed.get("agent_key")
|
|
||||||
if not agent_key:
|
if not agent_key:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Nous Portal refresh did not return a usable agent_key. "
|
"Nous Portal refresh did not return a usable agent_key. "
|
||||||
"Try `hermes login nous` to re-authenticate."
|
"Try `hermes login nous` to re-authenticate."
|
||||||
)
|
)
|
||||||
|
|
||||||
base_url = refreshed.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL
|
base_url = refreshed.get("base_url") or DEFAULT_NOUS_INFERENCE_URL
|
||||||
base_url = base_url.rstrip("/")
|
base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
return UpstreamCredential(
|
return UpstreamCredential(
|
||||||
bearer=agent_key,
|
bearer=agent_key,
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
expires_at=refreshed.get("agent_key_expires_at"),
|
expires_at=refreshed.get("expires_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -156,7 +153,8 @@ class NousPortalAdapter(UpstreamAdapter):
|
||||||
|
|
||||||
def _read_state(self) -> Optional[Dict[str, Any]]:
|
def _read_state(self) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
store = _load_auth_store()
|
with _auth_store_lock():
|
||||||
|
store = _load_auth_store()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("proxy: failed to load auth store: %s", exc)
|
logger.warning("proxy: failed to load auth store: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
@ -174,21 +172,20 @@ class NousPortalAdapter(UpstreamAdapter):
|
||||||
quarantine_reason: Optional[str] = None,
|
quarantine_reason: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
store = _load_auth_store()
|
with _auth_store_lock():
|
||||||
if quarantine_error is not None and quarantine_reason:
|
store = _load_auth_store()
|
||||||
_quarantine_nous_pool_entries(
|
if quarantine_error is not None and quarantine_reason:
|
||||||
store,
|
_quarantine_nous_pool_entries(
|
||||||
quarantine_error,
|
store,
|
||||||
reason=quarantine_reason,
|
quarantine_error,
|
||||||
)
|
reason=quarantine_reason,
|
||||||
providers = store.setdefault("providers", {})
|
)
|
||||||
providers["nous"] = state
|
providers = store.setdefault("providers", {})
|
||||||
_save_auth_store(store)
|
providers["nous"] = state
|
||||||
|
_save_auth_store(store)
|
||||||
_write_shared_nous_state(state)
|
_write_shared_nous_state(state)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Best effort — we still return the fresh credential. The next
|
logger.warning("proxy: failed to persist Nous quarantine state: %s", exc)
|
||||||
# request just won't see cached state, which means another refresh.
|
|
||||||
logger.warning("proxy: failed to persist refreshed Nous state: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["NousPortalAdapter"]
|
__all__ = ["NousPortalAdapter"]
|
||||||
|
|
|
||||||
|
|
@ -625,7 +625,7 @@ def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypa
|
||||||
"access_token": "manual-nous-key",
|
"access_token": "manual-nous-key",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _terminal_refresh_failure)
|
monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _terminal_refresh_failure)
|
||||||
|
|
||||||
assert pool.try_refresh_current() is None
|
assert pool.try_refresh_current() is None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1426,6 +1426,36 @@ def test_refresh_token_reuse_detection_surfaces_actionable_message():
|
||||||
assert exc_info.value.relogin_required is True
|
assert exc_info.value.relogin_required is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_token_reuse_error_code_is_terminal():
|
||||||
|
"""Nous may return refresh_token_reused as the OAuth error code itself."""
|
||||||
|
from hermes_cli import auth as auth_mod
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
status_code = 400
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"error": "refresh_token_reused",
|
||||||
|
"error_description": "Refresh token reuse detected",
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def post(self, *args, **kwargs):
|
||||||
|
return _FakeResponse()
|
||||||
|
|
||||||
|
with pytest.raises(AuthError) as exc_info:
|
||||||
|
auth_mod._refresh_access_token(
|
||||||
|
client=_FakeClient(),
|
||||||
|
portal_base_url="https://portal.nousresearch.com",
|
||||||
|
client_id="hermes-cli",
|
||||||
|
refresh_token="rt_consumed_elsewhere",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "refresh_token_reused"
|
||||||
|
assert exc_info.value.relogin_required is True
|
||||||
|
assert auth_mod._is_terminal_nous_refresh_error(exc_info.value) is True
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_token_exchange_sends_refresh_token_header():
|
def test_refresh_token_exchange_sends_refresh_token_header():
|
||||||
"""Nous refresh tokens must be sent in a header so sandbox proxies can
|
"""Nous refresh tokens must be sent in a header so sandbox proxies can
|
||||||
substitute placeholder credentials without parsing form bodies.
|
substitute placeholder credentials without parsing form bodies.
|
||||||
|
|
@ -1686,6 +1716,46 @@ def test_try_import_shared_returns_none_on_refresh_failure(
|
||||||
assert auth_mod._read_shared_nous_state() is None
|
assert auth_mod._read_shared_nous_state() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_import_shared_persists_rotated_token_when_mint_fails(
|
||||||
|
shared_store_env, monkeypatch,
|
||||||
|
):
|
||||||
|
"""A forced shared import refresh rotates the single-use token before minting.
|
||||||
|
|
||||||
|
If the later agent-key mint fails, the shared store must still keep the
|
||||||
|
rotated refresh token; otherwise the next import attempt replays the
|
||||||
|
consumed token and trips refresh-token reuse.
|
||||||
|
"""
|
||||||
|
from hermes_cli import auth as auth_mod
|
||||||
|
|
||||||
|
shared_state = _full_state_fixture()
|
||||||
|
shared_state["refresh_token"] = "refresh-old"
|
||||||
|
shared_state["access_token"] = "access-old"
|
||||||
|
auth_mod._write_shared_nous_state(shared_state)
|
||||||
|
|
||||||
|
def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token):
|
||||||
|
assert refresh_token == "refresh-old"
|
||||||
|
return {
|
||||||
|
"access_token": "access-new",
|
||||||
|
"refresh_token": "refresh-new",
|
||||||
|
"expires_in": 900,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds):
|
||||||
|
assert access_token == "access-new"
|
||||||
|
raise AuthError("credits exhausted", provider="nous", code="insufficient_credits")
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token)
|
||||||
|
monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key)
|
||||||
|
|
||||||
|
assert auth_mod._try_import_shared_nous_state() is None
|
||||||
|
|
||||||
|
shared_after = auth_mod._read_shared_nous_state()
|
||||||
|
assert shared_after is not None
|
||||||
|
assert shared_after["refresh_token"] == "refresh-new"
|
||||||
|
assert shared_after["access_token"] == "access-new"
|
||||||
|
|
||||||
|
|
||||||
def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
|
def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
|
||||||
"""Happy path: stored refresh_token is accepted, forced refresh+mint
|
"""Happy path: stored refresh_token is accepted, forced refresh+mint
|
||||||
returns a fresh access_token + agent_key, and the returned dict has
|
returns a fresh access_token + agent_key, and the returned dict has
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ def test_nous_adapter_authenticated_with_refresh_token_only(tmp_path, monkeypatc
|
||||||
assert NousPortalAdapter().is_authenticated()
|
assert NousPortalAdapter().is_authenticated()
|
||||||
|
|
||||||
|
|
||||||
def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatch):
|
def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_write_auth_store(tmp_path, {
|
_write_auth_store(tmp_path, {
|
||||||
"access_token": "access-tok",
|
"access_token": "access-tok",
|
||||||
|
|
@ -114,32 +114,24 @@ def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatc
|
||||||
})
|
})
|
||||||
|
|
||||||
refreshed_state = {
|
refreshed_state = {
|
||||||
"access_token": "access-tok",
|
"api_key": "minted-bearer",
|
||||||
"refresh_token": "refresh-tok",
|
"base_url": "https://inference-api.nousresearch.com/v1",
|
||||||
"client_id": "hermes-cli",
|
"expires_at": "2099-01-01T00:00:00Z",
|
||||||
"portal_base_url": "https://portal.nousresearch.com",
|
|
||||||
"inference_base_url": "https://inference-api.nousresearch.com/v1",
|
|
||||||
"agent_key": "minted-bearer",
|
|
||||||
"agent_key_expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
|
||||||
return_value=refreshed_state,
|
return_value=refreshed_state,
|
||||||
) as mock_refresh:
|
) as mock_resolve:
|
||||||
adapter = NousPortalAdapter()
|
adapter = NousPortalAdapter()
|
||||||
cred = adapter.get_credential()
|
cred = adapter.get_credential()
|
||||||
|
|
||||||
mock_refresh.assert_called_once()
|
mock_resolve.assert_called_once()
|
||||||
assert cred.bearer == "minted-bearer"
|
assert cred.bearer == "minted-bearer"
|
||||||
assert cred.base_url == "https://inference-api.nousresearch.com/v1"
|
assert cred.base_url == "https://inference-api.nousresearch.com/v1"
|
||||||
assert cred.expires_at == "2099-01-01T00:00:00Z"
|
assert cred.expires_at == "2099-01-01T00:00:00Z"
|
||||||
assert cred.token_type == "Bearer"
|
assert cred.token_type == "Bearer"
|
||||||
|
|
||||||
# Verify state was persisted back
|
|
||||||
stored = json.loads((tmp_path / "auth.json").read_text())
|
|
||||||
assert stored["providers"]["nous"]["agent_key"] == "minted-bearer"
|
|
||||||
|
|
||||||
|
|
||||||
def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch):
|
def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
|
@ -153,19 +145,15 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch)
|
||||||
})
|
})
|
||||||
|
|
||||||
refreshed_state = {
|
refreshed_state = {
|
||||||
"access_token": "jwt-access",
|
"api_key": "legacy-bearer",
|
||||||
"refresh_token": "refresh-tok",
|
"base_url": "https://inference-api.nousresearch.com/v1",
|
||||||
"client_id": "hermes-cli",
|
"expires_at": "2099-01-01T00:00:00Z",
|
||||||
"portal_base_url": "https://portal.nousresearch.com",
|
|
||||||
"inference_base_url": "https://inference-api.nousresearch.com/v1",
|
|
||||||
"agent_key": "legacy-bearer",
|
|
||||||
"agent_key_expires_at": "2099-01-01T00:00:00Z",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
|
||||||
return_value=refreshed_state,
|
return_value=refreshed_state,
|
||||||
) as mock_refresh:
|
) as mock_resolve:
|
||||||
adapter = NousPortalAdapter()
|
adapter = NousPortalAdapter()
|
||||||
cred = adapter.get_retry_credential(
|
cred = adapter.get_retry_credential(
|
||||||
failed_credential=UpstreamCredential(
|
failed_credential=UpstreamCredential(
|
||||||
|
|
@ -177,7 +165,7 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch)
|
||||||
|
|
||||||
assert cred is not None
|
assert cred is not None
|
||||||
assert cred.bearer == "legacy-bearer"
|
assert cred.bearer == "legacy-bearer"
|
||||||
assert mock_refresh.call_args.kwargs["inference_auth_mode"] == "legacy"
|
assert mock_resolve.call_args.kwargs["inference_auth_mode"] == "legacy"
|
||||||
|
|
||||||
|
|
||||||
def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch):
|
def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch):
|
||||||
|
|
@ -189,8 +177,8 @@ def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch
|
||||||
})
|
})
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
|
||||||
) as mock_refresh:
|
) as mock_resolve:
|
||||||
adapter = NousPortalAdapter()
|
adapter = NousPortalAdapter()
|
||||||
cred = adapter.get_retry_credential(
|
cred = adapter.get_retry_credential(
|
||||||
failed_credential=UpstreamCredential(
|
failed_credential=UpstreamCredential(
|
||||||
|
|
@ -201,7 +189,7 @@ def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch
|
||||||
)
|
)
|
||||||
|
|
||||||
assert cred is None
|
assert cred is None
|
||||||
mock_refresh.assert_not_called()
|
mock_resolve.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch):
|
def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch):
|
||||||
|
|
@ -219,7 +207,7 @@ def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeyp
|
||||||
})
|
})
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
|
||||||
side_effect=RuntimeError("Refresh session has been revoked"),
|
side_effect=RuntimeError("Refresh session has been revoked"),
|
||||||
):
|
):
|
||||||
adapter = NousPortalAdapter()
|
adapter = NousPortalAdapter()
|
||||||
|
|
@ -240,7 +228,7 @@ def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch
|
||||||
assert load_pool("nous").select() is not None
|
assert load_pool("nous").select() is not None
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
|
||||||
side_effect=AuthError(
|
side_effect=AuthError(
|
||||||
"Refresh session has been revoked",
|
"Refresh session has been revoked",
|
||||||
provider="nous",
|
provider="nous",
|
||||||
|
|
@ -270,7 +258,7 @@ def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path,
|
||||||
})
|
})
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
|
||||||
return_value={"access_token": "a", "refresh_token": "r"},
|
return_value={"access_token": "a", "refresh_token": "r"},
|
||||||
):
|
):
|
||||||
adapter = NousPortalAdapter()
|
adapter = NousPortalAdapter()
|
||||||
|
|
@ -291,7 +279,7 @@ def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch):
|
||||||
counter = [0]
|
counter = [0]
|
||||||
counter_lock = threading.Lock()
|
counter_lock = threading.Lock()
|
||||||
|
|
||||||
def serializing_refresh(state, **kwargs):
|
def serializing_refresh(**kwargs):
|
||||||
# If another thread is already inside refresh, the lock is broken.
|
# If another thread is already inside refresh, the lock is broken.
|
||||||
if in_flight.is_set():
|
if in_flight.is_set():
|
||||||
overlap_detected.set()
|
overlap_detected.set()
|
||||||
|
|
@ -305,10 +293,9 @@ def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch):
|
||||||
counter[0] += 1
|
counter[0] += 1
|
||||||
idx = counter[0]
|
idx = counter[0]
|
||||||
return {
|
return {
|
||||||
**state,
|
"api_key": f"key-{idx}",
|
||||||
"agent_key": f"key-{idx}",
|
"expires_at": "2099-01-01T00:00:00Z",
|
||||||
"agent_key_expires_at": "2099-01-01T00:00:00Z",
|
"base_url": "https://inference-api.nousresearch.com/v1",
|
||||||
"inference_base_url": "https://inference-api.nousresearch.com/v1",
|
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
in_flight.clear()
|
in_flight.clear()
|
||||||
|
|
@ -324,7 +311,7 @@ def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch):
|
||||||
errors.append(exc)
|
errors.append(exc)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state",
|
"hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials",
|
||||||
side_effect=serializing_refresh,
|
side_effect=serializing_refresh,
|
||||||
):
|
):
|
||||||
threads = [threading.Thread(target=worker) for _ in range(3)]
|
threads = [threading.Thread(target=worker) for _ in range(3)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue