From 34f34ba322b068edf242b6d9b1e0d1af01a41d1e Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sat, 16 May 2026 16:20:14 +0700 Subject: [PATCH] test(xai-oauth): pin tier-denied 403 behavior + docs warning for #26847 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests: * ``test_refresh_xai_oauth_pure_403_marked_tier_denied_not_relogin`` — refresh-403 raises ``xai_oauth_tier_denied`` with ``relogin_required=False`` and the API-key fallback hint in body. * ``test_format_auth_error_tier_denied_does_not_suggest_relogin`` — the renderer does not append "Run ``hermes model``" for the new code. * ``test_recover_with_credential_pool_skips_refresh_on_bare_403_for_xai_oauth`` — bare ``{"reason":"forbidden","message":"Forbidden"}`` body (which does not match the existing keyword heuristic) still short-circuits ``try_refresh_current`` on xai-oauth. Docs: * Drop the "(any active tier)" claim from the xai-grok-oauth guide, add a top-of-page warning callout, and a Troubleshooting section for the 403-after-login case pointing at ``XAI_API_KEY`` + ``provider: xai`` as the documented fallback. --- .../test_auth_xai_oauth_provider.py | 48 ++++++++++++++++++ .../test_codex_xai_oauth_recovery.py | 50 +++++++++++++++++++ website/docs/guides/xai-grok-oauth.md | 21 +++++++- 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py index dadf8f601ef..62f7accda9a 100644 --- a/tests/hermes_cli/test_auth_xai_oauth_provider.py +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -24,6 +24,7 @@ from hermes_cli.auth import ( _xai_oauth_build_authorize_url, _xai_start_callback_server, _xai_validate_loopback_redirect_uri, + format_auth_error, get_xai_oauth_auth_status, refresh_xai_oauth_pure, resolve_provider, @@ -732,6 +733,53 @@ def test_refresh_xai_oauth_pure_no_relogin_on_500(monkeypatch): assert exc.value.relogin_required is False +def test_refresh_xai_oauth_pure_403_marked_tier_denied_not_relogin(monkeypatch): + """403 from xAI's token endpoint is tier/entitlement, not stale tokens. + + Regression test for #26847 — xAI's backend has been seen to 403 + standard SuperGrok subscribers despite the in-app subscription + being active. Re-running ``hermes model`` won't help in that + case, so the AuthError must NOT set ``relogin_required=True``, + and must carry the dedicated ``xai_oauth_tier_denied`` code so + ``format_auth_error`` doesn't append the misleading re-auth hint. + """ + response = _StubHTTPResponse(403, {"error": "permission_denied"}) + _patch_httpx_client(monkeypatch, response) + with pytest.raises(AuthError) as exc: + refresh_xai_oauth_pure( + "at", "rt", token_endpoint="https://auth.x.ai/oauth2/token" + ) + assert exc.value.code == "xai_oauth_tier_denied" + assert exc.value.relogin_required is False + message = str(exc.value).lower() + assert "403" in message + assert "xai_api_key" in message + assert "tier" in message + + +def test_format_auth_error_tier_denied_does_not_suggest_relogin(): + """``xai_oauth_tier_denied`` must not append the re-authenticate hint. + + Regression for #26847: telling a tier-gated user to ``hermes model`` + is actively wrong — re-logging in won't change xAI's allowlist + decision. The full message (with ``XAI_API_KEY`` fallback) is built + into the error itself. + """ + err = AuthError( + "xAI token refresh failed with HTTP 403. Response: forbidden. " + "This OAuth account is not authorized for xAI API access — " + "xAI may be restricting API/OAuth use to specific SuperGrok tiers. " + "Set ``XAI_API_KEY`` and switch to ``provider: xai``.", + provider="xai-oauth", + code="xai_oauth_tier_denied", + relogin_required=False, + ) + rendered = format_auth_error(err) + assert "re-authenticate" not in rendered.lower() + assert "hermes model" not in rendered.lower() + assert "XAI_API_KEY" in rendered + + def test_refresh_xai_oauth_pure_returns_updated_tokens(monkeypatch): new_access = _jwt_with_exp(int(time.time()) + 3600) response = _StubHTTPResponse( diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 5cb48efc6c6..ea26783f10f 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -512,6 +512,56 @@ def test_recover_with_credential_pool_skips_refresh_on_entitlement_403(): assert refresh_calls["n"] == 0, "try_refresh_current must NOT be called on entitlement 403" +def test_recover_with_credential_pool_skips_refresh_on_bare_403_for_xai_oauth(): + """A bare HTTP 403 from ``xai-oauth`` (no keyword match) must NOT loop refresh. + + Regression for #26847 — xAI's backend has been seen to 403 standard + SuperGrok subscribers with a terser body that doesn't contain any of + the existing entitlement keywords ("do not have an active Grok + subscription", etc.). Before the defense-in-depth guard, the recovery + path would happily mint a fresh token, get a fresh 403, and spin. + """ + from run_agent import AIAgent + from agent.error_classifier import FailoverReason + + agent = _make_codex_agent() + assert agent.provider == "xai-oauth" + + refresh_calls = {"n": 0} + + class _FakePool: + def try_refresh_current(self): + refresh_calls["n"] += 1 + return MagicMock(id="should_not_be_called") + + def mark_exhausted_and_rotate(self, **_kwargs): + return None + + def has_available(self): + return False + + agent._credential_pool = _FakePool() + + error_context = { + "reason": "forbidden", + "message": "Forbidden", + } + assert not AIAgent._is_entitlement_failure(error_context, 403), ( + "Pre-condition: bare 'Forbidden' body must NOT match the keyword " + "heuristic — otherwise this test isn't covering the defense-in-depth path." + ) + + recovered, _retried_429 = agent._recover_with_credential_pool( + status_code=403, + has_retried_429=False, + classified_reason=FailoverReason.auth, + error_context=error_context, + ) + + assert recovered is False, "Bare 403 on xai-oauth must surface, not refresh-loop" + assert refresh_calls["n"] == 0, "try_refresh_current must NOT be called on xai-oauth 403" + + def test_recover_with_credential_pool_still_refreshes_genuine_auth_failure(): """Regression guard: legitimate auth errors must still trigger refresh.""" from run_agent import AIAgent diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index d85aa4c64bf..7057595c8d3 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -24,7 +24,7 @@ The same OAuth bearer token is also reused by every direct-to-xAI surface in Her | Endpoint | `https://api.x.ai/v1` | | Auth server | `https://accounts.x.ai` | | Requires env var | No (`XAI_API_KEY` is **not** used for this provider) | -| Subscription | [SuperGrok](https://x.ai/grok) (any active tier) | +| Subscription | [SuperGrok](https://x.ai/grok) — see note below | ## Prerequisites @@ -33,6 +33,10 @@ The same OAuth bearer token is also reused by every direct-to-xAI surface in Her - An active SuperGrok subscription on your xAI account - A browser available on the local machine (or use `--no-browser` for remote sessions) +:::warning xAI may restrict OAuth API access by tier +xAI's backend enforces its own allowlist on the OAuth API surface and has been seen to reject standard SuperGrok subscribers with `HTTP 403` (see issue [#26847](https://github.com/NousResearch/hermes-agent/issues/26847)) even though the in-app subscription is active. If OAuth login succeeds in the browser but inference returns 403, set `XAI_API_KEY` and switch to the API-key path (`provider: xai`) — that surface is not subject to the same gating today. +::: + ## Quick Start ```bash @@ -208,6 +212,21 @@ hermes auth add xai-oauth --no-browser Full walkthrough (jump boxes, mosh/tmux, port conflicts): [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md). +### HTTP 403 after a successful login (tier / entitlement) + +OAuth completed in the browser, tokens are saved, but inference or token refresh returns `HTTP 403` with a message similar to *"The caller does not have permission to execute the specified operation"*. + +This is **not** a stale-token problem — re-running `hermes model` won't change it. xAI's backend has been seen to restrict OAuth API access to specific SuperGrok tiers despite the in-app subscription being active (issue [#26847](https://github.com/NousResearch/hermes-agent/issues/26847)). + +**Fix:** set `XAI_API_KEY` and switch to the API-key path: + +```bash +export XAI_API_KEY=xai-... +hermes config set model.provider xai +``` + +Or upgrade your subscription at [x.ai/grok](https://x.ai/grok) if the OAuth route is required. + ### "No xAI credentials found" error at runtime The auth store has no `xai-oauth` entry and no `XAI_API_KEY` is set. You haven't logged in yet, or the credential file was deleted.