mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-08-13 17:16:12 +00:00
fix(honcho): skip memory calls while the oauth grant is dead
reauth_required() existed but nothing called it, so after a grant died every dialectic fire and sync flush still sent a Honcho API call that 401ed. dialectic_query and _flush_session now check the dead-grant flag first and skip the call: dialectic raises HonchoAuthError (exempt from cadence backoff), sync returns False with the failure recorded so the one-time notice still fires. The check compares the on-disk refresh-token digest, so a re-login flips it back with no network call and the next cadence resumes immediately. Transient auth errors keep the existing force-refresh-and-retry path. Four new tests: a dead grant issues no dialectic or sync call, and a re-login resumes both without waiting.
This commit is contained in:
@@ -56,6 +56,12 @@ def _auth_error_message(exc: BaseException) -> str:
|
||||
)
|
||||
|
||||
|
||||
_REAUTH_REQUIRED_MESSAGE = (
|
||||
"Honcho OAuth grant is revoked and cannot be refreshed; "
|
||||
"re-authenticate with 'hermes honcho setup'."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HonchoSession:
|
||||
"""
|
||||
@@ -219,6 +225,23 @@ class HonchoSessionManager:
|
||||
self._auth_notice_emitted = True
|
||||
return self._auth_failure
|
||||
|
||||
def _reauth_required(self) -> bool:
|
||||
"""True when the OAuth grant is dead and only a new login can fix it.
|
||||
|
||||
Reads the config and compares the refresh-token digest, so a re-login
|
||||
flips this back to False with no network call — recovery is immediate.
|
||||
"""
|
||||
try:
|
||||
from plugins.memory.honcho import oauth
|
||||
from plugins.memory.honcho.client import resolve_config_path
|
||||
|
||||
host = getattr(self._config, "host", "") or ""
|
||||
if not host:
|
||||
return False
|
||||
return oauth.reauth_required(resolve_config_path(), host)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _force_reauth(self) -> bool:
|
||||
"""Rotate the OAuth token after a server-side 401 and rebind the client.
|
||||
|
||||
@@ -512,6 +535,15 @@ class HonchoSessionManager:
|
||||
if not session.messages:
|
||||
return True
|
||||
|
||||
new_messages = [m for m in session.messages if not m.get("_synced")]
|
||||
if not new_messages:
|
||||
return True
|
||||
|
||||
# A dead grant cannot authenticate; skip the call until re-login.
|
||||
if self._reauth_required():
|
||||
self._record_auth_failure(HonchoAuthError(_REAUTH_REQUIRED_MESSAGE))
|
||||
return False
|
||||
|
||||
user_peer = self._get_or_create_peer(session.user_peer_id)
|
||||
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
honcho_session = self._sessions_cache.get(session.honcho_session_id)
|
||||
@@ -521,10 +553,6 @@ class HonchoSessionManager:
|
||||
session.honcho_session_id, user_peer, assistant_peer
|
||||
)
|
||||
|
||||
new_messages = [m for m in session.messages if not m.get("_synced")]
|
||||
if not new_messages:
|
||||
return True
|
||||
|
||||
honcho_messages = []
|
||||
for msg in new_messages:
|
||||
peer = user_peer if msg["role"] == "user" else assistant_peer
|
||||
@@ -750,6 +778,12 @@ class HonchoSessionManager:
|
||||
if not session:
|
||||
return ""
|
||||
|
||||
# A dead grant cannot authenticate; skip the call until re-login.
|
||||
if self._reauth_required():
|
||||
exc = HonchoAuthError(_REAUTH_REQUIRED_MESSAGE)
|
||||
self._record_auth_failure(exc)
|
||||
raise exc
|
||||
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id is None:
|
||||
return ""
|
||||
|
||||
@@ -398,6 +398,82 @@ class TestSyncAuthRetry:
|
||||
assert mgr._auth_failure is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dead grant: skip calls entirely until re-login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kill_grant(tmp_path, monkeypatch) -> Path:
|
||||
"""Revoke the grant on a tmp config and point the manager's path at it."""
|
||||
from plugins.memory.honcho import client as client_mod
|
||||
|
||||
path = tmp_path / "honcho.json"
|
||||
_write(path, {"hosts": {"hermes": _host_block()}})
|
||||
monkeypatch.setattr(oauth, "_REFRESH_RETRY_DELAY_SECONDS", 0)
|
||||
monkeypatch.setattr(
|
||||
oauth, "_http_post_form_status",
|
||||
lambda *a, **k: (400, {"error": "invalid_grant"}),
|
||||
)
|
||||
oauth.ensure_fresh_token(path, "hermes", now=1000)
|
||||
assert oauth.reauth_required(path, "hermes") is True
|
||||
monkeypatch.setattr(client_mod, "resolve_config_path", lambda: path)
|
||||
return path
|
||||
|
||||
|
||||
def _relogin(path: Path) -> None:
|
||||
oauth.install_grant(
|
||||
path, "hermes",
|
||||
{"access_token": "hch-at-fresh", "refresh_token": "hch-rt-fresh", "expires_in": 3600},
|
||||
client_id="hermes-desktop",
|
||||
token_endpoint="http://localhost:8000/oauth/token",
|
||||
)
|
||||
|
||||
|
||||
class TestDeadGrantSkipsCalls:
|
||||
def test_dead_grant_issues_no_dialectic_call(self, tmp_path, monkeypatch):
|
||||
_kill_grant(tmp_path, monkeypatch)
|
||||
peer = _FlakyPeer(failures=0)
|
||||
mgr = _make_manager(peer)
|
||||
with pytest.raises(HonchoAuthError):
|
||||
mgr.dialectic_query("k", "q")
|
||||
assert peer.calls == 0
|
||||
assert mgr.pop_auth_notice() is not None
|
||||
|
||||
def test_relogin_resumes_dialectic_without_waiting(self, tmp_path, monkeypatch):
|
||||
path = _kill_grant(tmp_path, monkeypatch)
|
||||
peer = _FlakyPeer(failures=0)
|
||||
mgr = _make_manager(peer)
|
||||
with pytest.raises(HonchoAuthError):
|
||||
mgr.dialectic_query("k", "q")
|
||||
assert peer.calls == 0
|
||||
|
||||
_relogin(path)
|
||||
assert mgr.dialectic_query("k", "q") == "synthesized answer"
|
||||
assert peer.calls == 1
|
||||
assert mgr._auth_failure is None
|
||||
|
||||
def test_dead_grant_issues_no_sync_call(self, tmp_path, monkeypatch):
|
||||
_kill_grant(tmp_path, monkeypatch)
|
||||
flaky = _FlakyHonchoSession(failures=0)
|
||||
mgr, session = _make_sync_manager(flaky)
|
||||
assert mgr._flush_session(session) is False
|
||||
assert flaky.calls == 0
|
||||
assert mgr._auth_failure is not None
|
||||
|
||||
def test_relogin_resumes_sync_without_waiting(self, tmp_path, monkeypatch):
|
||||
path = _kill_grant(tmp_path, monkeypatch)
|
||||
flaky = _FlakyHonchoSession(failures=0)
|
||||
mgr, session = _make_sync_manager(flaky)
|
||||
assert mgr._flush_session(session) is False
|
||||
assert flaky.calls == 0
|
||||
|
||||
_relogin(path)
|
||||
assert mgr._flush_session(session) is True
|
||||
assert flaky.calls == 1
|
||||
assert all(m["_synced"] for m in session.messages)
|
||||
assert mgr._auth_failure is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# one-time user-facing notice
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user