diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 795e5ea09f..fb75f7a8dc 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2253,7 +2253,40 @@ def resolve_nous_runtime_credentials( # ============================================================================= def get_nous_auth_status() -> Dict[str, Any]: - """Status snapshot for `hermes status` output.""" + """Status snapshot for `hermes status` output. + + Checks the credential pool first (where the dashboard device-code flow + and ``hermes auth`` store credentials), then falls back to the legacy + auth-store provider state. + """ + # Check credential pool first — the dashboard device-code flow saves + # here but may not have written to the auth store yet. + try: + from agent.credential_pool import load_pool + pool = load_pool("nous") + if pool and pool.has_credentials(): + entry = pool.select() + if entry is not None: + access_token = ( + getattr(entry, "access_token", None) + or getattr(entry, "runtime_api_key", "") + ) + if access_token: + return { + "logged_in": True, + "portal_base_url": getattr(entry, "portal_base_url", None) + or getattr(entry, "base_url", None), + "inference_base_url": getattr(entry, "inference_base_url", None) + or getattr(entry, "base_url", None), + "access_token": access_token, + "access_expires_at": getattr(entry, "expires_at", None), + "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None), + "has_refresh_token": bool(getattr(entry, "refresh_token", None)), + } + except Exception: + pass + + # Fall back to auth-store provider state state = get_provider_auth_state("nous") if not state: return { diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f8ae1eca87..89d60a2992 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1216,6 +1216,22 @@ def _nous_poller(session_id: str) -> None: "base_url": full_state.get("inference_base_url"), }) pool.add_entry(entry) + # Also persist to auth store so get_nous_auth_status() sees it + # (matches what _login_nous in auth.py does for the CLI flow). + try: + from hermes_cli.auth import ( + _load_auth_store, _save_provider_state, _save_auth_store, + _auth_store_lock, + ) + with _auth_store_lock(): + auth_store = _load_auth_store() + _save_provider_state(auth_store, "nous", full_state) + _save_auth_store(auth_store) + except Exception as store_exc: + _log.warning( + "oauth/device: credential pool saved but auth store write failed " + "(session=%s): %s", session_id, store_exc, + ) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: nous login completed (session=%s)", session_id) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 698d6b3725..457dc53de3 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -129,6 +129,76 @@ def _mint_payload(api_key: str = "agent-key") -> dict: } +def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch): + """get_nous_auth_status() should find Nous credentials in the pool + even when the auth store has no Nous provider entry — this is the + case when login happened via the dashboard device-code flow which + saves to the pool only. + """ + from hermes_cli.auth import get_nous_auth_status + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + # Empty auth store — no Nous provider entry + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, "providers": {}, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Seed the credential pool with a Nous entry + from agent.credential_pool import PooledCredential, load_pool + pool = load_pool("nous") + entry = PooledCredential.from_dict("nous", { + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "portal_base_url": "https://portal.example.com", + "inference_base_url": "https://inference.example.com/v1", + "agent_key": "test-agent-key", + "agent_key_expires_at": "2099-01-01T00:00:00+00:00", + "label": "dashboard device_code", + "auth_type": "oauth", + "source": "manual:dashboard_device_code", + "base_url": "https://inference.example.com/v1", + }) + pool.add_entry(entry) + + status = get_nous_auth_status() + assert status["logged_in"] is True + assert "example.com" in str(status.get("portal_base_url", "")) + + +def test_get_nous_auth_status_auth_store_fallback(tmp_path, monkeypatch): + """get_nous_auth_status() falls back to auth store when credential + pool is empty. + """ + from hermes_cli.auth import get_nous_auth_status + + hermes_home = tmp_path / "hermes" + _setup_nous_auth(hermes_home, access_token="at-123") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + status = get_nous_auth_status() + assert status["logged_in"] is True + assert status["portal_base_url"] == "https://portal.example.com" + + +def test_get_nous_auth_status_empty_returns_not_logged_in(tmp_path, monkeypatch): + """get_nous_auth_status() returns logged_in=False when both pool + and auth store are empty. + """ + from hermes_cli.auth import get_nous_auth_status + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, "providers": {}, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + status = get_nous_auth_status() + assert status["logged_in"] is False + + def test_refresh_token_persisted_when_mint_returns_insufficient_credits(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" _setup_nous_auth(hermes_home, refresh_token="refresh-old")