fix(auth): mirror Nous OAuth credentials to providers.nous on CLI login

`hermes auth add nous --type oauth` only wrote credential_pool.nous,
leaving providers.nous empty. When the Nous agent_key's 24h TTL expired,
run_agent.py's 401-recovery path called resolve_nous_runtime_credentials
(which reads providers.nous), got AuthError "Hermes is not logged into
Nous Portal", caught it as logger.debug (suppressed at INFO level), and
the agent died with "Non-retryable client error" — no signal to the
user that recovery even tried.

Introduce persist_nous_credentials() as the single source of truth for
Nous device-code login persistence. Both auth_commands (CLI) and
web_server (dashboard) now route through it, so pool and providers
stay in sync at write time.

Why: CLI-provisioned profiles couldn't recover from agent_key expiry,
producing silent daily outages 24h after first login. PR #6856/#6869
addressed adjacent issues but assumed providers.nous was populated;
this one wasn't being written.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Antoine Khater
2026-04-17 23:44:10 +00:00
committed by Teknium
parent a155b4a159
commit c096a6935f
5 changed files with 221 additions and 40 deletions

View File

@@ -2159,6 +2159,54 @@ def refresh_nous_oauth_from_state(
)
def persist_nous_credentials(
creds: Dict[str, Any],
*,
label: str,
source: str,
):
"""Persist minted Nous OAuth credentials to both auth-store sections.
Nous credentials are read at runtime from two independent locations:
- ``credential_pool.nous``: used by the runtime ``pool.select()`` path that
services outbound inference requests.
- ``providers.nous``: used by ``resolve_nous_runtime_credentials()`` — the
singleton-state reader invoked during 401 recovery and dashboard status
checks.
Historically ``hermes auth add nous`` wrote only to the pool while the web
dashboard device-code flow wrote to both, so CLI-provisioned profiles
failed silently when the recovery path was later consulted. This helper
is the single source of truth for CLI/web device-code persistence: both
stores are always written together.
Returns the added :class:`PooledCredential` entry.
"""
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
)
pool = load_pool("nous")
entry = PooledCredential.from_dict("nous", {
**creds,
"label": label,
"auth_type": AUTH_TYPE_OAUTH,
"source": source,
"base_url": creds.get("inference_base_url"),
})
pool.add_entry(entry)
with _auth_store_lock():
auth_store = _load_auth_store()
_save_provider_state(auth_store, "nous", creds)
_save_auth_store(auth_store)
return entry
def resolve_nous_runtime_credentials(
*,
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,

View File

@@ -221,15 +221,13 @@ def auth_add_command(args) -> None:
creds.get("access_token", ""),
_oauth_default_label(provider, len(pool.entries()) + 1),
)
entry = PooledCredential.from_dict(provider, {
**creds,
"label": label,
"auth_type": AUTH_TYPE_OAUTH,
"source": f"{SOURCE_MANUAL}:device_code",
"base_url": creds.get("inference_base_url"),
})
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
auth_mod.persist_nous_credentials(
creds,
label=label,
source=f"{SOURCE_MANUAL}:device_code",
)
count = len(load_pool(provider).entries())
print(f'Added {provider} OAuth credential #{count}: "{label}"')
return
if provider == "openai-codex":

View File

@@ -1444,38 +1444,13 @@ def _nous_poller(session_id: str) -> None:
auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0,
force_refresh=False, force_mint=True,
)
# Save into credential pool same as auth_commands.py does
from agent.credential_pool import (
PooledCredential,
load_pool,
AUTH_TYPE_OAUTH,
SOURCE_MANUAL,
from agent.credential_pool import SOURCE_MANUAL
from hermes_cli.auth import persist_nous_credentials
persist_nous_credentials(
full_state,
label="dashboard device_code",
source=f"{SOURCE_MANUAL}:dashboard_device_code",
)
pool = load_pool("nous")
entry = PooledCredential.from_dict("nous", {
**full_state,
"label": "dashboard device_code",
"auth_type": AUTH_TYPE_OAUTH,
"source": f"{SOURCE_MANUAL}:dashboard_device_code",
"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)