fix: dashboard shows Nous Portal as 'not connected' despite active auth (#9261)

The dashboard device-code flow (_nous_poller in web_server.py) saved
credentials to the credential pool only, while get_nous_auth_status()
only checked the auth store (auth.json). This caused the Keys tab to
show 'not connected' even when the backend was fully authenticated.

Two fixes:
1. get_nous_auth_status() now checks the credential pool first (like
   get_codex_auth_status() already does), then falls back to the auth
   store.
2. _nous_poller now also persists to the auth store after saving to
   the credential pool, matching the CLI flow (_login_nous).

Adds 3 tests covering pool-only, auth-store-fallback, and empty-state
scenarios.
This commit is contained in:
Teknium 2026-04-13 16:32:11 -07:00 committed by GitHub
parent 8d023e43ed
commit 32cea0c08d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 120 additions and 1 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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")