fix(nous-oauth): preserve obtained_at in pool + actionable message on RT reuse (#15111)

Two narrow fixes motivated by #15099.

1. _seed_from_singletons() was dropping obtained_at, agent_key_obtained_at,
   expires_in, and friends when seeding device_code pool entries from the
   providers.nous singleton. Fresh credentials showed up with
   obtained_at=None, which broke downstream freshness-sensitive consumers
   (self-heal hooks, pool pruning by age) — they treated just-minted
   credentials as older than they actually were and evicted them.

2. When the Nous Portal OAuth 2.1 server returns invalid_grant with
   'Refresh token reuse detected' in the error_description, rewrite the
   message to explain the likely cause (an external process consumed the
   rotated RT without persisting it back) and the mitigation. The generic
   reuse message led users to report this as a Hermes persistence bug when
   the actual trigger was typically a third-party monitoring script calling
   /api/oauth/token directly. Non-reuse errors keep their original server
   description untouched.

Closes #15099.

Regression tests:
- tests/agent/test_credential_pool.py::test_nous_seed_from_singletons_preserves_obtained_at_timestamps
- tests/hermes_cli/test_auth_nous_provider.py::test_refresh_token_reuse_detection_surfaces_actionable_message
- tests/hermes_cli/test_auth_nous_provider.py::test_refresh_non_reuse_error_keeps_original_description
This commit is contained in:
Teknium 2026-04-24 05:08:46 -07:00 committed by GitHub
parent 852c7f3be3
commit 78450c4bd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 183 additions and 0 deletions

View file

@ -1889,6 +1889,28 @@ def _refresh_access_token(
code = str(error_payload.get("error", "invalid_grant"))
description = str(error_payload.get("error_description") or "Refresh token exchange failed")
relogin = code in {"invalid_grant", "invalid_token"}
# Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal
# server and surface an actionable message. This fires when an external
# process (health-check script, monitoring tool, custom self-heal hook)
# called POST /api/oauth/token with Hermes's refresh_token without
# persisting the rotated token back to auth.json — the server then
# retires the original RT, Hermes's next refresh uses it, and the whole
# session chain gets revoked as a token-theft signal (#15099).
lowered = description.lower()
if "reuse" in lowered or "reuse detected" in lowered:
description = (
"Nous Portal detected refresh-token reuse and revoked this session.\n"
"This usually means an external process (monitoring script, "
"custom self-heal hook, or another Hermes install sharing "
"~/.hermes/auth.json) called POST /api/oauth/token with Hermes's "
"refresh token without persisting the rotated token back.\n"
"Nous refresh tokens are single-use — only Hermes may call the "
"refresh endpoint. For health checks, use `hermes auth status` "
"instead.\n"
"Re-authenticate with: hermes auth add nous"
)
raise AuthError(description, provider="nous", code=code, relogin_required=relogin)