test(xai-oauth): pin tier-denied 403 behavior + docs warning for #26847

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.
This commit is contained in:
xxxigm 2026-05-16 16:20:14 +07:00 committed by Teknium
parent 3b6f57fa66
commit 34f34ba322
3 changed files with 118 additions and 1 deletions

View file

@ -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(

View file

@ -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

View file

@ -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.