mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
fix: make Nous Portal access token resolution resilient
- Track auth store source path on Nous state reads and write rotated OAuth refresh tokens back to the same store, preventing stale-token replays when Hermes falls back to a global/root auth.json. - Skip Nous fallback entries locally when no access/refresh token is present, suppressing repeated failed resolution attempts within a session. - Sync session model metadata after fallback switches so the gateway DB reflects the backend that actually served the latest turn.
This commit is contained in:
parent
cfbc7ed1f9
commit
6ed2f5d76f
4 changed files with 276 additions and 32 deletions
|
|
@ -1157,6 +1157,36 @@ def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = N
|
|||
return auth_file
|
||||
|
||||
|
||||
def _load_provider_state_with_source(
|
||||
auth_store: Dict[str, Any],
|
||||
provider_id: str,
|
||||
) -> tuple[Optional[Dict[str, Any]], Optional[Path]]:
|
||||
"""Return a provider state plus the auth.json path it came from.
|
||||
|
||||
Most callers only need the state, but refresh paths that rotate single-use
|
||||
OAuth refresh tokens must write the updated token chain back to the same
|
||||
store they read. In profile mode ``_load_provider_state`` can read a
|
||||
global-root fallback state; persisting a rotated Nous refresh token only to
|
||||
the profile would leave the global/root store stale and cause the next
|
||||
process to replay an already-consumed refresh token.
|
||||
"""
|
||||
providers = auth_store.get("providers")
|
||||
if isinstance(providers, dict):
|
||||
state = providers.get(provider_id)
|
||||
if isinstance(state, dict):
|
||||
return dict(state), _auth_file_path()
|
||||
|
||||
global_path = _global_auth_file_path()
|
||||
global_store = _load_global_auth_store()
|
||||
if global_store:
|
||||
global_providers = global_store.get("providers")
|
||||
if isinstance(global_providers, dict):
|
||||
global_state = global_providers.get(provider_id)
|
||||
if isinstance(global_state, dict):
|
||||
return dict(global_state), global_path
|
||||
return None, None
|
||||
|
||||
|
||||
def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a provider's persisted state.
|
||||
|
||||
|
|
@ -1168,22 +1198,8 @@ def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Option
|
|||
the profile, the profile state fully shadows the global state on the next
|
||||
read. See issue #18594 follow-up.
|
||||
"""
|
||||
providers = auth_store.get("providers")
|
||||
if isinstance(providers, dict):
|
||||
state = providers.get(provider_id)
|
||||
if isinstance(state, dict):
|
||||
return dict(state)
|
||||
|
||||
# Read-only fallback to the global-root auth store (profile mode only;
|
||||
# returns empty dict in classic mode so this is a no-op).
|
||||
global_store = _load_global_auth_store()
|
||||
if global_store:
|
||||
global_providers = global_store.get("providers")
|
||||
if isinstance(global_providers, dict):
|
||||
global_state = global_providers.get(provider_id)
|
||||
if isinstance(global_state, dict):
|
||||
return dict(global_state)
|
||||
return None
|
||||
state, _source_path = _load_provider_state_with_source(auth_store, provider_id)
|
||||
return state
|
||||
|
||||
|
||||
def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None:
|
||||
|
|
@ -1195,6 +1211,30 @@ def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Di
|
|||
auth_store["active_provider"] = provider_id
|
||||
|
||||
|
||||
def _save_provider_state_to_source(
|
||||
auth_store: Dict[str, Any],
|
||||
provider_id: str,
|
||||
state: Dict[str, Any],
|
||||
source_path: Optional[Path],
|
||||
) -> None:
|
||||
"""Persist provider state back to the auth store it was read from."""
|
||||
active_path = _auth_file_path()
|
||||
if source_path is None:
|
||||
source_path = active_path
|
||||
try:
|
||||
same_store = source_path.resolve(strict=False) == active_path.resolve(strict=False)
|
||||
except Exception:
|
||||
same_store = source_path == active_path
|
||||
if same_store:
|
||||
_save_provider_state(auth_store, provider_id, state)
|
||||
_save_auth_store(auth_store)
|
||||
return
|
||||
|
||||
source_store = _load_auth_store(source_path)
|
||||
_save_provider_state(source_store, provider_id, state)
|
||||
_save_auth_store(source_store, target_path=source_path)
|
||||
|
||||
|
||||
def _store_provider_state(
|
||||
auth_store: Dict[str, Any],
|
||||
provider_id: str,
|
||||
|
|
@ -5337,7 +5377,7 @@ def resolve_nous_access_token(
|
|||
"""Resolve a refresh-aware Nous Portal access token for managed tool gateways."""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
state, state_source_path = _load_provider_state_with_source(auth_store, "nous")
|
||||
|
||||
if not state:
|
||||
raise AuthError(
|
||||
|
|
@ -5377,8 +5417,7 @@ def resolve_nous_access_token(
|
|||
|
||||
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
|
||||
if merged_shared:
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
_save_provider_state_to_source(auth_store, "nous", state, state_source_path)
|
||||
return access_token
|
||||
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
|
|
@ -5413,8 +5452,7 @@ def resolve_nous_access_token(
|
|||
exc,
|
||||
reason="managed_access_token_refresh_failure",
|
||||
)
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
_save_provider_state_to_source(auth_store, "nous", state, state_source_path)
|
||||
raise
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
|
@ -5435,8 +5473,7 @@ def resolve_nous_access_token(
|
|||
"insecure": verify is False,
|
||||
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||
}
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
_save_provider_state_to_source(auth_store, "nous", state, state_source_path)
|
||||
_write_shared_nous_state(state)
|
||||
return state["access_token"]
|
||||
|
||||
|
|
@ -5662,7 +5699,7 @@ def resolve_nous_runtime_credentials(
|
|||
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "nous")
|
||||
state, state_source_path = _load_provider_state_with_source(auth_store, "nous")
|
||||
|
||||
if not state:
|
||||
raise AuthError("Hermes is not logged into Nous Portal.",
|
||||
|
|
@ -5724,8 +5761,7 @@ def resolve_nous_runtime_credentials(
|
|||
)
|
||||
return
|
||||
try:
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
_save_provider_state_to_source(auth_store, "nous", state, state_source_path)
|
||||
except Exception as exc:
|
||||
_oauth_trace(
|
||||
"nous_state_persist_failed",
|
||||
|
|
@ -5904,7 +5940,7 @@ def resolve_nous_runtime_credentials(
|
|||
"expires_at": expires_at,
|
||||
"expires_in": expires_in,
|
||||
"source": NOUS_AUTH_PATH_INVOKE_JWT,
|
||||
"auth_path": NOUS_AUTH_PATH_INVOKE_JWT,
|
||||
"auth_path": str(state_source_path or _auth_file_path()),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue