diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 76cf4e94598b4..367b6e58f1572 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -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 "" diff --git a/tests/honcho_plugin/test_auth_recovery.py b/tests/honcho_plugin/test_auth_recovery.py index 15a0e4dd4a396..ef26f1e6552cd 100644 --- a/tests/honcho_plugin/test_auth_recovery.py +++ b/tests/honcho_plugin/test_auth_recovery.py @@ -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 # ---------------------------------------------------------------------------