From 37fa3c443ba9b1c88cbef821ac4bac750a6aa765 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 12:03:06 +1000 Subject: [PATCH 1/2] fix(acp): recover stale OAuth on long-lived ACP sessions ACP sessions omitted credential_pool from resolve_runtime_provider while gateway/CLI pass it through. After idle, xAI returns HTTP 403 bad-credentials for a stale access token; without the pool the turn aborted as non-retryable while Telegram/Desktop on the same home kept working. - Forward credential_pool into AIAgent from ACP session construction - Treat generic 403 auth like 401 for should_rotate_credential - Singleton xAI/Codex refresh also on 403 when classified as auth --- acp_adapter/session.py | 5 +++++ agent/conversation_loop.py | 10 +++++++-- agent/error_classifier.py | 4 ++++ tests/acp/test_session.py | 42 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 6f1e17a07f57..954971481160 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -634,6 +634,10 @@ def _make_agent( try: runtime = resolve_runtime_provider(requested=requested_provider or config_provider) + # Match gateway/CLI: pass credential_pool so long-lived ACP sessions + # can refresh OAuth (xAI returns 403 bad-credentials for stale tokens). + # Without the pool, idle Buzz ACP processes abort while Telegram/Desktop + # keep working on the same HERMES_HOME. kwargs.update( { "provider": runtime.get("provider"), @@ -642,6 +646,7 @@ def _make_agent( "api_key": runtime.get("api_key"), "command": runtime.get("command"), "args": list(runtime.get("args") or []), + "credential_pool": runtime.get("credential_pool"), } ) except Exception: diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a764501febab..97ae345a78ce 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3271,13 +3271,19 @@ def _perform_api_call(next_api_kwargs): if ( agent.api_mode == "codex_responses" and agent.provider in {"openai-codex", "xai-oauth"} - and status_code == 401 + # xAI often returns 403 bad-credentials for a stale access + # token (not only 401). Refresh on both when the classifier + # says auth (spending-limit 403 is billing and is excluded). + and status_code in {401, 403} + and classified.reason == FailoverReason.auth and not _retry.codex_auth_retry_attempted ): _retry.codex_auth_retry_attempted = True if agent._try_refresh_codex_client_credentials(force=True): _label = "xAI OAuth" if agent.provider == "xai-oauth" else "Codex" - agent._buffer_vprint(f"🔐 {_label} auth refreshed after 401. Retrying request...") + agent._buffer_vprint( + f"🔐 {_label} auth refreshed after {status_code}. Retrying request..." + ) continue if ( agent.api_mode == "chat_completions" diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 33c2f5458560..a42e6cdcd628 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -968,9 +968,13 @@ def _classify_by_status( should_rotate_credential=True, should_fallback=True, ) + # Generic 403 auth (incl. xAI stale OAuth ``bad-credentials``) must + # rotate/refresh like 401 so long-lived ACP/gateway sessions recover. + # Billing shapes above already returned; do not set rotate for those. return result_fn( FailoverReason.auth, retryable=False, + should_rotate_credential=True, should_fallback=True, ) diff --git a/tests/acp/test_session.py b/tests/acp/test_session.py index 199454b39dba..c58e7a215a85 100644 --- a/tests/acp/test_session.py +++ b/tests/acp/test_session.py @@ -130,6 +130,48 @@ def __init__(self, **kwargs): class TestWslCwdTranslation: + def test_make_agent_forwards_credential_pool_from_runtime(self, monkeypatch): + """ACP must wire the runtime credential pool like gateway/CLI. + + Without the pool, long-lived ACP sessions cannot refresh stale + xAI OAuth tokens (403 bad-credentials) while Telegram keeps working. + """ + captured = {} + fake_pool = object() + + def fake_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace( + model=kwargs.get("model"), + session_cwd=None, + _print_fn=None, + ) + + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider": "xai-oauth", "default": "grok-4.5"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda requested=None, **kwargs: { + "provider": "xai-oauth", + "api_mode": "codex_responses", + "base_url": "https://api.x.ai/v1", + "api_key": "stale-token", + "command": None, + "args": [], + "credential_pool": fake_pool, + }, + ) + with patch("run_agent.AIAgent", side_effect=fake_agent): + agent = SessionManager(db=None)._make_agent( + session_id="sess-pool", + cwd="/tmp/work", + ) + + assert captured.get("credential_pool") is fake_pool + assert captured.get("provider") == "xai-oauth" + def test_translate_acp_cwd_converts_windows_drive_path_when_wsl(self, monkeypatch): monkeypatch.setattr("hermes_constants._wsl_detected", True) From 68fca449d69aa642cec84be8f2d743bf5a4a5cf1 Mon Sep 17 00:00:00 2001 From: joelbrilliant Date: Fri, 24 Jul 2026 12:17:12 +1000 Subject: [PATCH 2/2] fix(acp): only rotate credentials on OAuth-shaped 403 auth Generic OpenRouter 403 auth must keep should_rotate_credential=False (historical behaviour). Enable rotation for xAI/Codex/Nous and explicit bad-credentials / OAuth2-validation bodies only. --- agent/error_classifier.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index a42e6cdcd628..dba9cae5d91e 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -968,13 +968,21 @@ def _classify_by_status( should_rotate_credential=True, should_fallback=True, ) - # Generic 403 auth (incl. xAI stale OAuth ``bad-credentials``) must - # rotate/refresh like 401 so long-lived ACP/gateway sessions recover. - # Billing shapes above already returned; do not set rotate for those. + # Default: auth without rotate — preserve historical behaviour for + # generic providers (see test_non_xai_403_generic_billing_code_remains_auth). + # OAuth providers that return 403 for stale access tokens (xAI + # ``bad-credentials``, etc.) need rotate/refresh like 401 so long-lived + # ACP sessions recover. Billing shapes above already returned. + _stale_oauth_403 = ( + provider in {"xai-oauth", "openai-codex", "nous"} + or "bad-credentials" in error_msg + or "oauth2 access token could not be validated" in error_msg + or "wke=unauthenticated" in error_msg + ) return result_fn( FailoverReason.auth, retryable=False, - should_rotate_credential=True, + should_rotate_credential=_stale_oauth_403, should_fallback=True, )