mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
852c7f3be3
commit
78450c4bd6
4 changed files with 183 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue