Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,22 @@ def recover_with_credential_pool(
return False, True

if effective_reason == FailoverReason.auth:
if agent._is_entitlement_failure(error_context, status_code):
# Subscription/entitlement 403s look like auth failures on the wire
# but refresh cannot fix them — the OAuth token is already valid,
# the account simply lacks the entitlement. Without this guard,
# ``try_refresh_current()`` keeps minting fresh tokens against the
# same unsubscribed account and the main agent loop spins re-issuing
# the same 403 until the user Ctrl+C's.
#
# Defense-in-depth for #26847: xAI's backend has been seen to 403
# standard SuperGrok subscribers with bodies that don't match the
# existing entitlement keyword set in ``_is_entitlement_failure``.
# Any 403 against ``xai-oauth`` is treated as entitlement here so
# the refresh loop can't spin in those cases either.
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
is_entitlement = True
if is_entitlement:
_ra().logger.info(
"Credential %s — entitlement-shaped 403 from %s; "
"skipping pool refresh (account lacks subscription, "
Expand Down
43 changes: 42 additions & 1 deletion hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3443,12 +3443,34 @@ def refresh_xai_oauth_pure(
)
if response.status_code != 200:
detail = response.text.strip()
# ``403`` from xAI's token endpoint is almost always a tier /
# entitlement gate (the OAuth grant exists but the account isn't
# on the allowlist for API access). Re-running ``hermes model``
# won't fix that — surface a separate error code so
# ``format_auth_error`` doesn't append a misleading
# re-authenticate hint, and point users at the ``XAI_API_KEY``
# fallback. See #26847.
if response.status_code == 403:
raise AuthError(
"xAI token refresh failed with HTTP 403."
+ (f" Response: {detail}" if detail else "")
+ " This OAuth account is not authorized for xAI API"
" access — xAI may be restricting API/OAuth use to"
" specific SuperGrok tiers despite the in-app"
" subscription being active. Re-logging in won't"
" change that; set ``XAI_API_KEY`` and switch to"
" ``provider: xai`` (API-key path) if available, or"
" upgrade your subscription at https://x.ai/grok.",
provider="xai-oauth",
code="xai_oauth_tier_denied",
relogin_required=False,
)
raise AuthError(
"xAI token refresh failed."
+ (f" Response: {detail}" if detail else ""),
provider="xai-oauth",
code="xai_refresh_failed",
relogin_required=(response.status_code in {400, 401, 403}),
relogin_required=(response.status_code in {400, 401}),
)
try:
payload = response.json()
Expand Down Expand Up @@ -6225,6 +6247,25 @@ def _xai_oauth_exchange_code_for_tokens(

if response.status_code != 200:
body = response.text.strip()
# See ``refresh_xai_oauth_pure`` — token-exchange 403 also
# surfaces tier/entitlement gating from xAI's backend. Avoid
# the misleading "re-authenticate" hint and point at the API
# key fallback. See #26847.
if response.status_code == 403:
raise AuthError(
f"xAI token exchange failed (HTTP 403)."
+ (f" Response: {body}" if body else "")
+ " This OAuth account is not authorized for xAI API"
" access — xAI may be restricting API/OAuth use to"
" specific SuperGrok tiers despite the in-app"
" subscription being active. Set ``XAI_API_KEY``"
" and switch to ``provider: xai`` (API-key path) if"
" available, or upgrade your subscription at"
" https://x.ai/grok.",
provider="xai-oauth",
code="xai_oauth_tier_denied",
relogin_required=False,
)
raise AuthError(
f"xAI token exchange failed (HTTP {response.status_code})."
+ (f" Response: {body}" if body else ""),
Expand Down
48 changes: 48 additions & 0 deletions tests/hermes_cli/test_auth_xai_oauth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
_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,
Expand Down Expand Up @@ -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(
Expand Down
50 changes: 50 additions & 0 deletions tests/run_agent/test_codex_xai_oauth_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,56 @@ def has_available(self):
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
Expand Down
21 changes: 20 additions & 1 deletion website/docs/guides/xai-grok-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading