mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(auth): use refresh-free Nous status snapshot on read-only display paths
Follow-up widening of the /api/status fix: add get_nous_auth_status_local(), a refresh-free auth-store snapshot (local invoke-JWT decode only), and use it on the read-only display surfaces that previously called get_nous_auth_status() -> resolve_nous_runtime_credentials() -> live OAuth refresh POST: - hermes_cli/status.py (hermes status auth-provider panel) - hermes_cli/doctor.py (hermes doctor auth-provider checks) - hermes_cli/portal_cli.py (hermes portal status display) - hermes_cli/web_server.py /api/portal endpoint and the accounts-tab provider card dispatcher (_resolve_provider_status nous branch) Action paths (login flows, portal operations needing a live credential) keep using get_nous_auth_status(). Part of NS-592.
This commit is contained in:
parent
0764163f80
commit
5ea2e0a0bc
9 changed files with 176 additions and 27 deletions
|
|
@ -6519,6 +6519,69 @@ def _compute_nous_auth_status() -> Dict[str, Any]:
|
|||
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.
|
||||
|
||||
Use this from status panels, doctor checks, and polled dashboard
|
||||
endpoints. Explicit auth actions (login flows, portal operations that
|
||||
need a live credential) should keep using ``get_nous_auth_status()``.
|
||||
|
||||
``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.
|
||||
"""
|
||||
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: Dict[str, Any] = {
|
||||
"logged_in": logged_in,
|
||||
"portal_base_url": state.get("portal_base_url"),
|
||||
"inference_base_url": state.get("inference_base_url"),
|
||||
"access_token": access_token,
|
||||
"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")),
|
||||
"inference_credential_present": bool(
|
||||
access_token or state.get("agent_key")
|
||||
),
|
||||
"credential_source": "auth_store",
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -1356,12 +1356,14 @@ def run_doctor(args):
|
|||
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
get_nous_auth_status,
|
||||
get_nous_auth_status_local,
|
||||
get_codex_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
|
||||
nous_status = get_nous_auth_status()
|
||||
# Read-only display: refresh-free snapshot — doctor must never
|
||||
# trigger an OAuth refresh as a side effect of a health check.
|
||||
nous_status = get_nous_auth_status_local()
|
||||
if nous_status.get("logged_in"):
|
||||
check_ok("Nous Portal auth", "(logged in)")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -33,13 +33,14 @@ DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-
|
|||
|
||||
def _cmd_status(args) -> int:
|
||||
"""Show Portal auth + Tool Gateway routing summary."""
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.auth import get_nous_auth_status_local
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
config = load_config() or {}
|
||||
|
||||
try:
|
||||
auth = get_nous_auth_status() or {}
|
||||
# Read-only status display: refresh-free snapshot (no OAuth refresh).
|
||||
auth = get_nous_auth_status_local() or {}
|
||||
except Exception:
|
||||
auth = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -195,12 +195,14 @@ def show_status(args):
|
|||
|
||||
try:
|
||||
from hermes_cli.auth import (
|
||||
get_nous_auth_status,
|
||||
get_nous_auth_status_local,
|
||||
get_codex_auth_status,
|
||||
get_qwen_auth_status,
|
||||
get_minimax_oauth_auth_status,
|
||||
)
|
||||
nous_status = get_nous_auth_status()
|
||||
# Read-only display: use the refresh-free snapshot so `hermes status`
|
||||
# never performs an OAuth refresh or burns a single-use refresh token.
|
||||
nous_status = get_nous_auth_status_local()
|
||||
codex_status = get_codex_auth_status()
|
||||
qwen_status = get_qwen_auth_status()
|
||||
minimax_status = get_minimax_oauth_auth_status()
|
||||
|
|
|
|||
|
|
@ -3696,9 +3696,11 @@ async def get_portal_status():
|
|||
cfg = load_config() or {}
|
||||
auth: Dict[str, Any] = {}
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.auth import get_nous_auth_status_local
|
||||
|
||||
auth = get_nous_auth_status() or {}
|
||||
# Read-only dashboard endpoint: refresh-free snapshot so polling
|
||||
# never performs an OAuth refresh or burns a refresh token.
|
||||
auth = get_nous_auth_status_local() or {}
|
||||
except Exception:
|
||||
auth = {}
|
||||
|
||||
|
|
@ -10331,7 +10333,9 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
|||
try:
|
||||
from hermes_cli import auth as hauth
|
||||
if provider_id == "nous":
|
||||
raw = hauth.get_nous_auth_status()
|
||||
# Read-only accounts-tab card: refresh-free snapshot so listing
|
||||
# providers never performs an OAuth refresh.
|
||||
raw = hauth.get_nous_auth_status_local()
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": "nous_portal",
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ class TestDoctorMemoryProviderSection:
|
|||
# Stub auth checks to avoid real API calls
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -461,7 +461,7 @@ def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, t
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -499,7 +499,7 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path):
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -539,7 +539,7 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
|
|||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -589,7 +589,7 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases(
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -636,7 +636,7 @@ def test_run_doctor_accepts_vendor_slugs_for_named_custom_provider(monkeypatch,
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -683,7 +683,7 @@ def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path):
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
|
|
@ -723,7 +723,7 @@ def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -763,7 +763,7 @@ def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch,
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except Exception:
|
||||
|
|
@ -812,7 +812,7 @@ def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(mon
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except ImportError:
|
||||
|
|
@ -871,7 +871,7 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path
|
|||
|
||||
try:
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {})
|
||||
except ImportError:
|
||||
|
|
@ -1031,7 +1031,7 @@ def _run_doctor_with_healthy_oauth_fallback(
|
|||
|
||||
from hermes_cli import auth as _auth_mod
|
||||
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status)
|
||||
_xai_status = xai_oauth_status if xai_oauth_status is not None else {}
|
||||
|
|
@ -1162,7 +1162,7 @@ class TestDoctorXaiOAuthStatus:
|
|||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn)
|
||||
|
|
@ -1236,7 +1236,7 @@ class TestDoctorXaiOAuthStatus:
|
|||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
||||
|
|
@ -1267,7 +1267,7 @@ class TestDoctorXaiOAuthStatus:
|
|||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False)
|
||||
|
|
@ -1327,7 +1327,7 @@ class TestDoctorCodexCliHintPlacement:
|
|||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
from hermes_cli import auth as _auth_mod
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": codex_logged_in})
|
||||
monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False})
|
||||
monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False})
|
||||
|
|
|
|||
|
|
@ -154,3 +154,77 @@ def test_provider_state_exception_is_unknown(monkeypatch):
|
|||
_block_live_auth(monkeypatch)
|
||||
|
||||
assert get_nous_session_validity() == NOUS_SESSION_UNKNOWN
|
||||
|
||||
|
||||
# ── get_nous_auth_status_local — refresh-free display snapshot ──
|
||||
|
||||
|
||||
def test_local_status_logged_in_with_usable_jwt_never_refreshes(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"get_provider_auth_state",
|
||||
lambda provider: {
|
||||
"access_token": _invoke_jwt(),
|
||||
"refresh_token": "rt",
|
||||
"scope": auth.DEFAULT_NOUS_SCOPE,
|
||||
"portal_base_url": "https://portal.example",
|
||||
},
|
||||
)
|
||||
_block_live_auth(monkeypatch)
|
||||
|
||||
status = auth.get_nous_auth_status_local()
|
||||
assert status["logged_in"] is True
|
||||
assert status["source"] == "auth_store_local"
|
||||
assert status["portal_base_url"] == "https://portal.example"
|
||||
|
||||
|
||||
def test_local_status_logged_in_via_refresh_token_when_jwt_expired(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"get_provider_auth_state",
|
||||
lambda provider: {
|
||||
"access_token": _invoke_jwt(seconds=-60),
|
||||
"refresh_token": "rt",
|
||||
"scope": auth.DEFAULT_NOUS_SCOPE,
|
||||
},
|
||||
)
|
||||
_block_live_auth(monkeypatch)
|
||||
|
||||
# Runtime can still refresh — display should not claim logged-out.
|
||||
assert auth.get_nous_auth_status_local()["logged_in"] is True
|
||||
|
||||
|
||||
def test_local_status_not_logged_in_after_terminal_quarantine(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"get_provider_auth_state",
|
||||
lambda provider: {
|
||||
"last_auth_error": {
|
||||
"relogin_required": True,
|
||||
"code": "invalid_grant",
|
||||
},
|
||||
},
|
||||
)
|
||||
_block_live_auth(monkeypatch)
|
||||
|
||||
status = auth.get_nous_auth_status_local()
|
||||
assert status["logged_in"] is False
|
||||
assert status["relogin_required"] is True
|
||||
assert status["error_code"] == "invalid_grant"
|
||||
|
||||
|
||||
def test_local_status_repeated_polling_never_uses_live_auth(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"get_provider_auth_state",
|
||||
lambda provider: {
|
||||
"access_token": _invoke_jwt(),
|
||||
"refresh_token": "rt",
|
||||
"scope": auth.DEFAULT_NOUS_SCOPE,
|
||||
},
|
||||
)
|
||||
_block_live_auth(monkeypatch)
|
||||
|
||||
assert all(
|
||||
auth.get_nous_auth_status_local()["logged_in"] for _ in range(10)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_
|
|||
|
||||
monkeypatch.setattr(status_mod, "get_env_value", _get_env_value, raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "get_nous_auth_status_local", lambda: {}, raising=False
|
||||
)
|
||||
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(
|
||||
status_mod.subprocess,
|
||||
|
|
@ -135,7 +138,7 @@ def test_show_status_reports_exhausted_nous_credits(monkeypatch, capsys, tmp_pat
|
|||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"get_nous_auth_status",
|
||||
"get_nous_auth_status_local",
|
||||
lambda: {
|
||||
"logged_in": False,
|
||||
"access_token": "jwt",
|
||||
|
|
|
|||
|
|
@ -867,7 +867,7 @@ def test_status_hardcoded_branch_wins_over_generic_fallback():
|
|||
import hermes_cli.web_server as ws
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.get_nous_auth_status",
|
||||
"hermes_cli.auth.get_nous_auth_status_local",
|
||||
return_value={"logged_in": True, "portal_base_url": "https://portal.test"},
|
||||
):
|
||||
out = ws._resolve_provider_status("nous", None)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue