From 5ea2e0a0bc45cd2a1f56a1ccc553f356aef57907 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:57:19 -0700 Subject: [PATCH] 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. --- hermes_cli/auth.py | 63 ++++++++++++++++ hermes_cli/doctor.py | 6 +- hermes_cli/portal_cli.py | 5 +- hermes_cli/status.py | 6 +- hermes_cli/web_server.py | 10 ++- tests/hermes_cli/test_doctor.py | 32 ++++---- .../hermes_cli/test_nous_session_validity.py | 74 +++++++++++++++++++ .../hermes_cli/test_status_model_provider.py | 5 +- tests/hermes_cli/test_web_oauth_dispatch.py | 2 +- 9 files changed, 176 insertions(+), 27 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 037bf04034ec..c380f3af8375 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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 diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index c5581045ec5f..8a361c5cf1da 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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: diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index 485b05ce7d0b..76e5553f3902 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -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 = {} diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 4a1b0e123793..de7921c20a59 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -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() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 55bbd1f1bdbf..3c4f9d85eb25 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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", diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 76ba52110416..8ded2affd119 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -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}) diff --git a/tests/hermes_cli/test_nous_session_validity.py b/tests/hermes_cli/test_nous_session_validity.py index 2c5adaf68bf4..d891b91309e7 100644 --- a/tests/hermes_cli/test_nous_session_validity.py +++ b/tests/hermes_cli/test_nous_session_validity.py @@ -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) + ) diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index f6bc7553b6d6..c921476c9fa1 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -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", diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index a8ec8c669c98..f4725f069228 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -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)