diff --git a/dev-notes/architecture/index.md b/dev-notes/architecture/index.md index 6b5fc05d7..1cfc5e904 100644 --- a/dev-notes/architecture/index.md +++ b/dev-notes/architecture/index.md @@ -65,6 +65,8 @@ For the practical frontend contract, see [Building a Custom TUI](../custom-tui.m - [Phase 23: Advanced TUI and Product Polish](./phase-23-tui-polish.md) - [Bounded TUI Transcript Rendering](./tui-long-transcript-performance.md) - [Phase 24: Session Tree Branching](./phase-24-session-tree-branching.md) +- [OAuth provider parity](./oauth-provider-parity.md) +- [xAI SuperGrok / X Premium OAuth](./xai-oauth.md) Phase 21 extensions are implemented; see the phase note and the user guide at `website/content/guides/extensions.md`. diff --git a/dev-notes/architecture/oauth-provider-parity.md b/dev-notes/architecture/oauth-provider-parity.md index e3769b286..abf9550c9 100644 --- a/dev-notes/architecture/oauth-provider-parity.md +++ b/dev-notes/architecture/oauth-provider-parity.md @@ -54,7 +54,8 @@ tau_coding.oauth_registry │ OAuthProvider protocol ├── OpenAI Codex ├── Anthropic - └── GitHub Copilot + ├── GitHub Copilot + └── xAI │ ▼ FileCredentialStore ── OAuthRuntimeCredentialResolver ── tau_ai adapter @@ -135,6 +136,7 @@ users can update or delete old local entries safely. ## Follow-ups +- xAI SuperGrok / X Premium device-code OAuth is implemented separately; see [xAI OAuth](./xai-oauth.md). - Add OpenAI Codex device-code login and an explicit browser/device selector. - Add a frontend-neutral non-TUI login command for SSH-only use. - Consider process-level refresh locking if Tau introduces concurrent processes diff --git a/dev-notes/architecture/xai-oauth.md b/dev-notes/architecture/xai-oauth.md new file mode 100644 index 000000000..8307f113c --- /dev/null +++ b/dev-notes/architecture/xai-oauth.md @@ -0,0 +1,97 @@ +# xAI SuperGrok / X Premium OAuth + +Issue: [#676](https://github.com/huggingface/tau/issues/676) + +## What this adds + +xAI was already a built-in OpenAI-compatible catalog provider, but login was +API-key only. This change registers a Tau-owned device-code OAuth provider so +SuperGrok / X Premium users can run `/login xai` without a paid API key. + +The existing `XAI_API_KEY` path stays available: + +```text +auth_methods = ["api_key", "oauth"] +``` + +This is a provider-specific follow-up to the registry from +[OAuth provider parity](./oauth-provider-parity.md). It does not change +`tau_agent` or add a new auth architecture. + +## Architecture + +```text +Textual OAuthLoginScreen + │ OAuthLoginCallbacks (device code, progress, cancellation) + ▼ +tau_coding.oauth_registry + │ OAuthProvider protocol + └── XaiOAuthProvider (oauth_xai.py) + │ + ▼ +FileCredentialStore ── OAuthRuntimeCredentialResolver ── OpenAI-compatible adapter +``` + +`tau_coding` owns the device-code flow, refresh, credential storage, and +`/login` policy. The existing `api.x.ai` OpenAI-compatible adapter in `tau_ai` +receives only a Bearer access token. `tau_agent` is unchanged. + +Device-code requests identify Tau as the client with `referrer=tau`. Login, +refresh, and runtime auth follow the same `OAuthProvider` contract as Anthropic, +Codex, and Copilot. + +## Behavior + +- `/login` → Subscription lists xAI (SuperGrok / X Premium). +- `/login xai` and `/login xai-subscription` start RFC 8628 device authorization + against `auth.x.ai`: show the verification URL and user code, then poll until + authorized, denied, cancelled, or expired. `/login xai-api` saves an API key. +- Successful login stores `access` / `refresh` / `expires` under + `~/.tau/credentials.json`. +- Runtime uses the access token as the Bearer credential for `https://api.x.ai/v1`. +- Refresh happens before expiry. If xAI omits `refresh_token` on refresh, Tau + keeps the previous refresh token. +- `/logout xai` removes the local credential. It does not remotely revoke the + grant. +- Print mode works after login: `tau --provider xai --model -p "..."`. + +API-key login remains available through `/login xai-api`, `/login` → API key, +`XAI_API_KEY`, or a saved key. + +## Security choices + +- Device verification URLs are accepted only with an `https` scheme and host. +- Failed OAuth responses may include structured `error` / `error_description` + text. Request secrets such as refresh tokens are scrubbed from that text. +- Credentials remain in `~/.tau/credentials.json` with mode `0600`. The file is + not encrypted. +- Tests use `httpx.MockTransport` and fake credentials. CI does not contact + `auth.x.ai` or require real secrets. + +Device-code requests send `referrer=tau`. The current public client ID is the +one `auth.x.ai` already accepts for CLI device login; replace it if xAI issues a +Tau-owned client. + +## How to test + +```bash +uv run pytest tests/test_oauth_providers.py tests/test_provider_catalog.py::test_builtin_catalog_oauth_and_opencode_auth_methods +uv run ruff check . +uv run ruff format --check . +uv run mypy +``` + +The mocked tests cover successful login with `referrer=tau`, authorization +pending, denial, expiry, untrusted verification URIs, malformed JSON, refresh +token rotation, and omitted `refresh_token` on refresh. + +Live subscription smoke tests are not automated. Before a release, verify +`/login xai` once with a SuperGrok or X Premium account, including one +headless/SSH device-code path. Do not copy tokens, device codes, or credential +file contents into GitHub. + +## Rollback + +Remove `XaiOAuthProvider` from `oauth_registry.py` and drop `oauth` from the xAI +catalog `auth_methods`. Existing local OAuth objects remain parseable so users +can `/logout xai` or keep using `XAI_API_KEY`. diff --git a/src/tau_coding/commands.py b/src/tau_coding/commands.py index 97f387740..71d6b60b6 100644 --- a/src/tau_coding/commands.py +++ b/src/tau_coding/commands.py @@ -24,6 +24,8 @@ LOGIN_PROVIDER_ALIASES = { "anthropic-api": ("anthropic", "api-key"), "anthropic-subscription": ("anthropic", "subscription"), + "xai-api": ("xai", "api-key"), + "xai-subscription": ("xai", "subscription"), } @@ -761,6 +763,8 @@ def _login_command(context: CommandContext) -> CommandResult: aliased_provider = LOGIN_PROVIDER_ALIASES.get(provider_name) if aliased_provider is not None: provider_name, login_method = aliased_provider + elif provider_name == "xai": + login_method = "subscription" else: login_method = None entry = builtin_provider_entry(provider_name) diff --git a/src/tau_coding/data/catalog.toml b/src/tau_coding/data/catalog.toml index c71918c23..8b59227a4 100644 --- a/src/tau_coding/data/catalog.toml +++ b/src/tau_coding/data/catalog.toml @@ -889,6 +889,7 @@ kind = "openai-compatible" base_url = "https://api.x.ai/v1" api_key_env = "XAI_API_KEY" credential_name = "xai" +auth_methods = ["api_key", "oauth"] models = ["grok-3", "grok-3-fast", "grok-4.20-0309-non-reasoning", "grok-4.20-0309-reasoning", "grok-4.3", "grok-build-0.1", "grok-code-fast-1"] default_model = "grok-4.20-0309-reasoning" docs_url = "https://docs.x.ai" diff --git a/src/tau_coding/oauth_registry.py b/src/tau_coding/oauth_registry.py index 699f8c007..110993879 100644 --- a/src/tau_coding/oauth_registry.py +++ b/src/tau_coding/oauth_registry.py @@ -8,9 +8,15 @@ from tau_coding.oauth_anthropic import AnthropicOAuthProvider from tau_coding.oauth_github_copilot import GitHubCopilotOAuthProvider from tau_coding.oauth_types import OAuthProvider +from tau_coding.oauth_xai import XaiOAuthProvider _BUILTIN_PROVIDERS: tuple[OAuthProvider, ...] = tuple( - [AnthropicOAuthProvider(), GitHubCopilotOAuthProvider(), OpenAICodexOAuthProvider()] + [ + AnthropicOAuthProvider(), + GitHubCopilotOAuthProvider(), + OpenAICodexOAuthProvider(), + XaiOAuthProvider(), + ] ) _registry: dict[str, OAuthProvider] = {provider.id: provider for provider in _BUILTIN_PROVIDERS} diff --git a/src/tau_coding/oauth_xai.py b/src/tau_coding/oauth_xai.py new file mode 100644 index 000000000..bb680040e --- /dev/null +++ b/src/tau_coding/oauth_xai.py @@ -0,0 +1,288 @@ +"""xAI SuperGrok / X Premium OAuth device-code provider.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable, Iterable +from typing import Any +from urllib.parse import urlparse + +import httpx + +from tau_ai.http import create_async_client +from tau_coding.credentials import OAuthCredential +from tau_coding.oauth import OAuthError, oauth_credential_is_expired +from tau_coding.oauth_device import DevicePollResult, poll_oauth_device_code +from tau_coding.oauth_types import ( + OAuthDeviceCodeInfo, + OAuthFlowKind, + OAuthLoginCallbacks, + OAuthRuntimeAuth, +) + +XAI_OAUTH_PROVIDER = "xai" +# Public xAI device-code client currently accepted by auth.x.ai for CLI login. +# Device-code requests identify Tau with referrer=tau. Replace this ID if xAI +# issues a Tau-owned client. +XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" +XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access" +XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code" +XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token" +XAI_REFERRER = "tau" +XAI_TOKEN_SKEW_MS = 5 * 60 * 1000 +DEFAULT_TOKEN_LIFETIME_SECONDS = 3600 +HTTP_TIMEOUT_SECONDS = 30.0 + + +async def login_xai( + callbacks: OAuthLoginCallbacks, + *, + client: httpx.AsyncClient | None = None, + cancel_event: asyncio.Event | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, +) -> OAuthCredential: + """Run xAI's device-code flow and return credentials to persist.""" + owns_client = client is None + active_client = client or create_async_client(timeout=HTTP_TIMEOUT_SECONDS) + try: + info, device_code = await _request_device_code(active_client) + callbacks.on_device_code(info) + return await poll_oauth_device_code( + lambda: _poll_for_tokens(device_code, active_client), + interval_seconds=info.interval_seconds, + expires_in_seconds=info.expires_in_seconds, + wait_before_first_poll=True, + cancel_event=cancel_event, + sleep=sleep if sleep is not None else asyncio.sleep, + ) + finally: + if owns_client: + await active_client.aclose() + + +async def refresh_xai_token( + credential: OAuthCredential, + *, + client: httpx.AsyncClient | None = None, +) -> OAuthCredential: + """Exchange a refresh token for a new xAI access token.""" + owns_client = client is None + active_client = client or create_async_client(timeout=HTTP_TIMEOUT_SECONDS) + try: + ok, status, body = await _post_form( + XAI_TOKEN_URL, + { + "grant_type": "refresh_token", + "client_id": XAI_CLIENT_ID, + "refresh_token": credential.refresh, + }, + client=active_client, + ) + finally: + if owns_client: + await active_client.aclose() + if not ok: + raise _request_failure( + "token refresh", + status, + body, + secrets=[credential.refresh], + ) + # xAI may omit refresh_token on refresh; keep the previous one then. + return _credential_from_token_response( + body, + previous_refresh_token=credential.refresh, + ) + + +async def _post_form( + url: str, + fields: dict[str, str], + *, + client: httpx.AsyncClient, +) -> tuple[bool, int, dict[str, Any]]: + """POST one urlencoded OAuth request and return (ok, status, json body).""" + try: + response = await client.post( + url, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + data=fields, + ) + except httpx.HTTPError as exc: + raise OAuthError(f"xAI OAuth request failed: {exc}") from exc + try: + body = response.json() + except ValueError: + raise OAuthError(f"xAI OAuth returned invalid JSON (HTTP {response.status_code})") from None + if not isinstance(body, dict): + raise OAuthError(f"xAI OAuth returned invalid JSON (HTTP {response.status_code})") + return response.is_success, response.status_code, body + + +def _required_string(body: dict[str, Any], field: str) -> str: + value = body.get(field) + if not isinstance(value, str) or not value: + raise OAuthError(f"Invalid xAI OAuth response field: {field}") + return value + + +def _positive_number(body: dict[str, Any], field: str) -> int: + value = body.get(field) + if not isinstance(value, int | float) or isinstance(value, bool) or not value > 0: + raise OAuthError(f"Invalid xAI OAuth response field: {field}") + return int(value) + + +def _optional_interval(body: dict[str, Any]) -> float | None: + if body.get("interval") is None: + return None + return float(_positive_number(body, "interval")) + + +def _https_url(raw: str) -> str: + parsed = urlparse(raw) + if parsed.scheme != "https" or not parsed.netloc: + raise OAuthError("Untrusted verification URI in xAI OAuth response") + return raw + + +def _error_detail(body: dict[str, Any], *, secrets: Iterable[str] = ()) -> str | None: + """Return structured OAuth error text with request secrets scrubbed.""" + error = body.get("error") if isinstance(body.get("error"), str) else None + description = ( + body.get("error_description") if isinstance(body.get("error_description"), str) else None + ) + detail = ": ".join(part for part in (error, description) if part) + if not detail: + return None + for secret in secrets: + if len(secret) >= 8: + detail = detail.replace(secret, "") + return detail[:200] + + +def _request_failure( + action: str, + status: int, + body: dict[str, Any], + *, + secrets: Iterable[str] = (), +) -> OAuthError: + detail = _error_detail(body, secrets=secrets) + suffix = f": {detail}" if detail else "" + return OAuthError(f"xAI OAuth {action} failed (HTTP {status}){suffix}") + + +def _credential_from_token_response( + body: dict[str, Any], + previous_refresh_token: str | None, +) -> OAuthCredential: + access = _required_string(body, "access_token") + raw_refresh = body.get("refresh_token") + if raw_refresh is None: + if previous_refresh_token is None: + raise OAuthError("Invalid xAI OAuth response field: refresh_token") + refresh = previous_refresh_token + else: + refresh = _required_string(body, "refresh_token") + expires_in = body.get("expires_in") + expires_in_seconds = ( + _positive_number(body, "expires_in") + if expires_in is not None + else DEFAULT_TOKEN_LIFETIME_SECONDS + ) + return OAuthCredential( + access=access, + refresh=refresh, + expires=int(time.time() * 1000) + expires_in_seconds * 1000 - XAI_TOKEN_SKEW_MS, + ) + + +async def _request_device_code(client: httpx.AsyncClient) -> tuple[OAuthDeviceCodeInfo, str]: + ok, status, body = await _post_form( + XAI_DEVICE_CODE_URL, + {"client_id": XAI_CLIENT_ID, "scope": XAI_SCOPE, "referrer": XAI_REFERRER}, + client=client, + ) + if not ok: + raise _request_failure("device authorization", status, body) + info = OAuthDeviceCodeInfo( + user_code=_required_string(body, "user_code"), + verification_uri=_https_url(_required_string(body, "verification_uri")), + interval_seconds=_optional_interval(body), + expires_in_seconds=float(_positive_number(body, "expires_in")), + ) + complete = body.get("verification_uri_complete") + if isinstance(complete, str) and complete: + info = OAuthDeviceCodeInfo( + user_code=info.user_code, + verification_uri=_https_url(complete), + interval_seconds=info.interval_seconds, + expires_in_seconds=info.expires_in_seconds, + ) + return info, _required_string(body, "device_code") + + +async def _poll_for_tokens( + device_code: str, + client: httpx.AsyncClient, +) -> DevicePollResult[OAuthCredential]: + ok, status, body = await _post_form( + XAI_TOKEN_URL, + { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": XAI_CLIENT_ID, + "device_code": device_code, + }, + client=client, + ) + if ok: + return DevicePollResult( + status="complete", + value=_credential_from_token_response(body, previous_refresh_token=None), + ) + error = body.get("error") + if error == "authorization_pending": + return DevicePollResult(status="pending") + if error == "slow_down": + interval = body.get("interval") + return DevicePollResult( + status="slow_down", + interval_seconds=float(interval) if isinstance(interval, int | float) else None, + ) + if error in ("access_denied", "authorization_denied"): + return DevicePollResult(status="failed", message="xAI device authorization was denied") + if error == "expired_token": + return DevicePollResult(status="failed", message="xAI device code expired") + return DevicePollResult( + status="failed", + message=_request_failure( + "device token polling", + status, + body, + secrets=[device_code], + ).args[0], + ) + + +class XaiOAuthProvider: + """xAI Grok subscription OAuth via the auth.x.ai device-code flow.""" + + id = XAI_OAUTH_PROVIDER + name = "xAI (SuperGrok/X Premium)" + flow_kinds: tuple[OAuthFlowKind, ...] = ("device_code",) + + async def login(self, callbacks: OAuthLoginCallbacks) -> OAuthCredential: + return await login_xai(callbacks) + + async def refresh(self, credential: OAuthCredential) -> OAuthCredential: + if not oauth_credential_is_expired(credential): + return credential + return await refresh_xai_token(credential) + + def runtime_auth(self, credential: OAuthCredential) -> OAuthRuntimeAuth: + return OAuthRuntimeAuth(api_key=credential.access) diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index 6ca44e111..57cb72d99 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -6218,14 +6218,18 @@ def _logout(self, provider_name: str) -> None: self._notify(NO_STORED_CREDENTIALS_MESSAGE, severity="warning") return + had_oauth = credential_store.get_oauth(entry.credential_name) is not None try: credential_store.delete(entry.credential_name) - self.session.reload_provider_settings() except Exception as exc: # noqa: BLE001 - surface logout failures in the TUI self._notify(f"Could not log out: {exc}", severity="error") return + with suppress(ProviderConfigError, RuntimeError): + # openai-compatible OAuth providers rebuild with _api_key_from_provider. + # After deleting the stored grant that is expected; the credential is gone. + self.session.reload_provider_settings() - if entry.kind == "openai-codex": + if had_oauth or entry.kind == "openai-codex": self._notify(f"Logged out of {entry.display_name}.") else: self._notify( diff --git a/tests/test_commands.py b/tests/test_commands.py index d516e18bd..9c385f68f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -450,12 +450,30 @@ def test_login_command_resolves_anthropic_auth_aliases(tmp_path: Path) -> None: assert subscription_result.login_method == "subscription" +def test_login_command_resolves_xai_auth_aliases(tmp_path: Path) -> None: + registry = create_default_command_registry() + session = FakeSession(tmp_path) + + default_result = registry.execute(session, "/login xai") + api_result = registry.execute(session, "/login xai-api") + subscription_result = registry.execute(session, "/login xai-subscription") + + assert default_result.login_provider == "xai" + assert default_result.login_method == "subscription" + assert api_result.login_provider == "xai" + assert api_result.login_method == "api-key" + assert subscription_result.login_provider == "xai" + assert subscription_result.login_method == "subscription" + + def test_login_command_lists_auth_aliases_for_unknown_provider(tmp_path: Path) -> None: result = create_default_command_registry().execute(FakeSession(tmp_path), "/login missing") assert result.message is not None assert "anthropic-api" in result.message assert "anthropic-subscription" in result.message + assert "xai-api" in result.message + assert "xai-subscription" in result.message def test_login_command_requests_custom_provider_login(tmp_path: Path) -> None: diff --git a/tests/test_oauth_providers.py b/tests/test_oauth_providers.py index ca105eee9..6dc36e804 100644 --- a/tests/test_oauth_providers.py +++ b/tests/test_oauth_providers.py @@ -1,5 +1,6 @@ import asyncio from typing import cast +from urllib.parse import parse_qs import httpx import pytest @@ -34,6 +35,16 @@ OAuthRuntimeAuth, OAuthSelectPrompt, ) +from tau_coding.oauth_xai import ( + XAI_CLIENT_ID, + XAI_DEVICE_CODE_URL, + XAI_REFERRER, + XAI_SCOPE, + XAI_TOKEN_URL, + XaiOAuthProvider, + login_xai, + refresh_xai_token, +) from tau_coding.provider_config import provider_config_from_catalog_entry from tau_coding.provider_runtime import OAuthRuntimeCredentialResolver, _refresh_lock @@ -57,6 +68,14 @@ async def on_select(_prompt: OAuthSelectPrompt) -> str | None: ) +def _form(request: httpx.Request) -> dict[str, str]: + return {key: values[0] for key, values in parse_qs(request.content.decode()).items()} + + +async def _no_sleep(_seconds: float) -> None: + return None + + @pytest.mark.anyio async def test_refresh_anthropic_token_uses_json_and_redacts_failed_response() -> None: def handler(request: httpx.Request) -> httpx.Response: @@ -255,6 +274,207 @@ def handler(request: httpx.Request) -> httpx.Response: ) +@pytest.mark.anyio +async def test_xai_device_login_sends_tau_referrer_and_returns_tokens() -> None: + device_codes: list[OAuthDeviceCodeInfo] = [] + token_polls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_polls + if str(request.url) == XAI_DEVICE_CODE_URL: + fields = _form(request) + assert fields["client_id"] == XAI_CLIENT_ID + assert fields["scope"] == XAI_SCOPE + assert fields["referrer"] == XAI_REFERRER == "tau" + return httpx.Response( + 200, + json={ + "device_code": "device-secret", + "user_code": "ABCD-1234", + "verification_uri": "https://auth.x.ai/activate", + "verification_uri_complete": "https://auth.x.ai/activate?user_code=ABCD-1234", + "interval": 1, + "expires_in": 60, + }, + ) + if str(request.url) == XAI_TOKEN_URL: + fields = _form(request) + assert fields["client_id"] == XAI_CLIENT_ID + assert fields["device_code"] == "device-secret" + assert fields["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code" + token_polls += 1 + if token_polls == 1: + return httpx.Response(400, json={"error": "authorization_pending"}) + return httpx.Response( + 200, + json={ + "access_token": "xai-access", + "refresh_token": "xai-refresh", + "expires_in": 3600, + }, + ) + raise AssertionError(f"Unexpected request: {request.url}") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + credential = await login_xai( + _callbacks(device_codes=device_codes), + client=client, + sleep=_no_sleep, + ) + + assert device_codes == [ + OAuthDeviceCodeInfo( + user_code="ABCD-1234", + verification_uri="https://auth.x.ai/activate?user_code=ABCD-1234", + interval_seconds=1, + expires_in_seconds=60, + ) + ] + assert credential.access == "xai-access" + assert credential.refresh == "xai-refresh" + assert credential.expires > 0 + assert XaiOAuthProvider().runtime_auth(credential).api_key == "xai-access" + + +@pytest.mark.anyio +async def test_xai_device_login_rejects_untrusted_verification_uri() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "device_code": "device", + "user_code": "code", + "verification_uri": "file:///tmp/not-safe", + "interval": 5, + "expires_in": 60, + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(OAuthError, match="Untrusted verification URI"): + await login_xai(_callbacks(), client=client, sleep=_no_sleep) + + +@pytest.mark.anyio +async def test_xai_device_login_denial_and_expiry() -> None: + async def run(error: str, message: str) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == XAI_DEVICE_CODE_URL: + return httpx.Response( + 200, + json={ + "device_code": "device", + "user_code": "CODE", + "verification_uri": "https://auth.x.ai/activate", + "interval": 1, + "expires_in": 60, + }, + ) + return httpx.Response(400, json={"error": error}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(OAuthError, match=message): + await login_xai(_callbacks(), client=client, sleep=_no_sleep) + + await run("access_denied", "xAI device authorization was denied") + await run("expired_token", "xAI device code expired") + + +@pytest.mark.anyio +async def test_xai_device_login_rejects_malformed_json() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="not-json") + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(OAuthError, match="invalid JSON"): + await login_xai(_callbacks(), client=client, sleep=_no_sleep) + + +@pytest.mark.anyio +async def test_refresh_xai_token_rotates_refresh_token() -> None: + def handler(request: httpx.Request) -> httpx.Response: + fields = _form(request) + assert str(request.url) == XAI_TOKEN_URL + assert fields["grant_type"] == "refresh_token" + assert fields["refresh_token"] == "old-refresh" + assert fields["client_id"] == XAI_CLIENT_ID + return httpx.Response( + 200, + json={ + "access_token": "new-access", + "refresh_token": "new-refresh", + "expires_in": 3600, + }, + ) + + original = OAuthCredential(access="old-access", refresh="old-refresh", expires=1) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + refreshed = await refresh_xai_token(original, client=client) + + assert refreshed.access == "new-access" + assert refreshed.refresh == "new-refresh" + + +@pytest.mark.anyio +async def test_refresh_xai_token_keeps_previous_refresh_when_omitted() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"access_token": "new-access", "expires_in": 3600}, + ) + + original = OAuthCredential(access="old-access", refresh="keep-me", expires=1) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + refreshed = await refresh_xai_token(original, client=client) + + assert refreshed.access == "new-access" + assert refreshed.refresh == "keep-me" + + +@pytest.mark.anyio +async def test_refresh_xai_token_redacts_failed_response_body() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 401, + json={ + "error": "invalid_grant", + "error_description": "secret-token-body", + }, + ) + + original = OAuthCredential(access="old-access", refresh="refresh-secret", expires=1) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(OAuthError) as error: + await refresh_xai_token(original, client=client) + + message = str(error.value) + assert "401" in message + assert "invalid_grant" in message + assert "secret-token-body" in message + assert "refresh-secret" not in message + + +@pytest.mark.anyio +async def test_refresh_xai_token_scrubs_echoed_refresh_token() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={ + "error": "invalid_grant", + "error_description": "token refresh-secret is malformed", + }, + ) + + original = OAuthCredential(access="old-access", refresh="refresh-secret", expires=1) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + with pytest.raises(OAuthError) as error: + await refresh_xai_token(original, client=client) + + message = str(error.value) + assert "invalid_grant: token is malformed" in message + assert "refresh-secret" not in message + + @pytest.mark.anyio async def test_device_poll_slow_down_and_cancel() -> None: sleeps: list[float] = [] @@ -410,8 +630,12 @@ async def contend() -> asyncio.Lock: def test_builtin_oauth_registry_matches_supported_subscription_providers() -> None: - assert oauth_provider_ids() == {"anthropic", "github-copilot", "openai-codex"} + assert oauth_provider_ids() == {"anthropic", "github-copilot", "openai-codex", "xai"} anthropic = get_oauth_provider("anthropic") assert anthropic is not None assert anthropic.name == "Anthropic (Claude Pro/Max)" + xai = get_oauth_provider("xai") + assert xai is not None + assert xai.name == "xAI (SuperGrok/X Premium)" + assert xai.flow_kinds == ("device_code",) assert get_oauth_provider("missing") is None diff --git a/tests/test_oauth_tui.py b/tests/test_oauth_tui.py index 95af01052..052227d9d 100644 --- a/tests/test_oauth_tui.py +++ b/tests/test_oauth_tui.py @@ -101,6 +101,33 @@ async def fake_login(callbacks): assert "ABCD-1234" in help_text +@pytest.mark.anyio +async def test_xai_oauth_device_code_screen_shows_user_code() -> None: + provider = builtin_provider_entry("xai") + assert provider is not None + + async def fake_login(callbacks): + callbacks.on_device_code( + OAuthDeviceCodeInfo( + user_code="WXYZ-5678", + verification_uri="https://auth.x.ai/activate", + ) + ) + await asyncio.Event().wait() + + screen = OAuthLoginScreen(provider, theme=TAU_DARK_THEME, login=fake_login) + copied: list[str] = [] + app = _themed_app(screen) + app.copy_to_clipboard = copied.append # type: ignore[method-assign] + async with app.run_test(size=(100, 40)) as pilot: + await pilot.pause() + await pilot.pause() + help_text = str(screen.query_one("#login-help", Static).render()) + + assert copied == [] + assert "WXYZ-5678" in help_text + + @pytest.mark.anyio async def test_oauth_screen_fits_a_short_terminal() -> None: """Growing for the URL must not push the paste field off a small screen.""" diff --git a/tests/test_provider_catalog.py b/tests/test_provider_catalog.py index c6ef97d52..453ae6227 100644 --- a/tests/test_provider_catalog.py +++ b/tests/test_provider_catalog.py @@ -267,6 +267,8 @@ def test_builtin_catalog_oauth_and_opencode_auth_methods() -> None: assert copilot is not None and copilot.auth_methods == ("oauth",) assert opencode_go is not None and opencode_go.auth_methods == ("api_key",) assert opencode is not None and opencode.auth_methods == ("api_key",) + xai = builtin_provider_entry("xai") + assert xai is not None and xai.auth_methods == ("api_key", "oauth") assert opencode_go.api_key_env == "OPENCODE_API_KEY" assert opencode.api_key_env == "OPENCODE_API_KEY" diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index f76601b9d..74ecbcab8 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -72,6 +72,7 @@ from tau_coding.provider_config import ( OpenAICodexProviderConfig, OpenAICompatibleProviderConfig, + ProviderConfigError, ProviderSelection, ProviderSettings, ScopedModelConfig, @@ -329,6 +330,18 @@ def handle_command(self, text: str) -> CommandResult: login_provider="anthropic", login_method="subscription", ) + if text == "/login xai-api": + return CommandResult( + handled=True, + login_provider="xai", + login_method="api-key", + ) + if text in {"/login xai", "/login xai-subscription"}: + return CommandResult( + handled=True, + login_provider="xai", + login_method="subscription", + ) if text.startswith("/login "): return CommandResult(handled=True, login_provider=text.removeprefix("/login ")) if text == "/logout": @@ -7047,6 +7060,74 @@ async def test_tui_anthropic_api_alias_opens_api_key_login() -> None: assert app.screen.provider.name == "anthropic" +@pytest.mark.anyio +async def test_tui_xai_login_opens_oauth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + login_started = asyncio.Event() + + class FakeOAuthProvider: + async def login(self, _callbacks: object) -> OAuthCredential: + login_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + fake_provider = FakeOAuthProvider() + monkeypatch.setattr(tui_app, "get_oauth_provider", lambda _name: fake_provider) + app = TauTuiApp(FakeSession()) + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt") + prompt.value = "/login xai" + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, OAuthLoginScreen) + assert app.screen.provider.name == "xai" + assert login_started.is_set() + + +@pytest.mark.anyio +async def test_tui_xai_subscription_alias_opens_oauth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + login_started = asyncio.Event() + + class FakeOAuthProvider: + async def login(self, _callbacks: object) -> OAuthCredential: + login_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + fake_provider = FakeOAuthProvider() + monkeypatch.setattr(tui_app, "get_oauth_provider", lambda _name: fake_provider) + app = TauTuiApp(FakeSession()) + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt") + prompt.value = "/login xai-subscription" + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, OAuthLoginScreen) + assert app.screen.provider.name == "xai" + assert login_started.is_set() + + +@pytest.mark.anyio +async def test_tui_xai_api_alias_opens_api_key_login() -> None: + app = TauTuiApp(FakeSession()) + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt") + prompt.value = "/login xai-api" + await pilot.press("enter") + await pilot.pause() + + assert isinstance(app.screen, LoginScreen) + assert app.screen.provider.name == "xai" + + @pytest.mark.anyio async def test_tui_login_openai_codex_saves_oauth_credentials( monkeypatch: pytest.MonkeyPatch, @@ -7294,6 +7375,52 @@ def fake_notify(message: str, **kwargs: object) -> None: assert notifications == ["Logged out of OpenAI Codex subscription."] +@pytest.mark.anyio +async def test_tui_logout_xai_oauth_does_not_fail_as_missing_api_key( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + isolate_home(monkeypatch, tmp_path) + credential_path = tmp_path / ".tau" / "credentials.json" + FileCredentialStore(credential_path).set_oauth( + "xai", + OAuthCredential( + access="access-token", + refresh="refresh-token", + expires=123456, + ), + ) + + class ReloadingSession(FakeSession): + def reload_provider_settings(self) -> None: + super().reload_provider_settings() + raise ProviderConfigError( + "Missing provider API key. Set XAI_API_KEY or run /login xai." + ) + + session = ReloadingSession() + session.provider_name = "xai" + app = TauTuiApp(session) + notifications: list[tuple[str, str | None]] = [] + + def fake_notify(message: str, **kwargs: object) -> None: + severity = kwargs.get("severity") + notifications.append((message, severity if isinstance(severity, str) else None)) + + app._notify = fake_notify # type: ignore[method-assign] + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt") + prompt.value = "/logout xai" + await pilot.press("enter") + await pilot.pause() + + assert FileCredentialStore(credential_path).get_oauth("xai") is None + assert session.provider_reload_count == 1 + assert notifications == [("Logged out of xAI.", None)] + assert all("API key" not in message for message, _severity in notifications) + + @pytest.mark.anyio async def test_tui_logout_opens_stored_credential_provider_picker( monkeypatch: pytest.MonkeyPatch, @@ -7430,6 +7557,7 @@ async def test_tui_login_subscription_opens_oauth_provider_picker() -> None: assert labels == [ "OpenAI Codex subscription — openai-codex", "Anthropic — anthropic", + "xAI — xai", "GitHub Copilot — github-copilot", ] assert "gpt-5.5" not in "\n".join(labels) @@ -7529,6 +7657,7 @@ async def test_tui_login_api_key_opens_api_provider_picker() -> None: labels = [str(item.query_one(Label).render()) for item in provider_list.children] assert labels[0] == "OpenAI — openai" assert "OpenAI Codex subscription — openai-codex" not in labels + assert "xAI — xai" in labels await pilot.press("down") await pilot.press("enter") diff --git a/website/content/guides/providers-and-models.md b/website/content/guides/providers-and-models.md index 0c0bb8a6a..a1a8520a7 100644 --- a/website/content/guides/providers-and-models.md +++ b/website/content/guides/providers-and-models.md @@ -23,13 +23,15 @@ tau /login anthropic-subscription # authenticate Claude Pro/Max via OAuth /login anthropic-api # save an Anthropic API key /login github-copilot # authenticate GitHub Copilot with a device code +/login xai # authenticate SuperGrok/X Premium via device code +/login xai-api # save an xAI API key /login opencode-go # save an OpenCode Go API key /login nvidia # save an NVIDIA NIM API key /login custom # add an OpenAI-compatible custom provider ``` Built-in providers include **OpenAI**, **Anthropic**, **OpenAI Codex** -(subscription), **GitHub Copilot**, **OpenCode Go**, **OpenCode Zen**, +(subscription), **GitHub Copilot**, **xAI**, **OpenCode Go**, **OpenCode Zen**, **Moonshot AI (Kimi)**, **Kimi Code** (subscription), **OpenRouter**, **Hugging Face**, and **NVIDIA NIM**. @@ -42,16 +44,24 @@ Choose **Subscription / OAuth** in `/login` for: | `openai-codex` | Browser callback with pasted-code fallback | A supported ChatGPT/Codex subscription | | `anthropic` | Browser callback with PKCE and pasted-code fallback | Claude Pro/Max with Anthropic extra usage available | | `github-copilot` | GitHub device code | An active Copilot plan; organization policy must allow the selected model | +| `xai` | xAI device code | SuperGrok or X Premium; API-key login remains available | GitHub Copilot asks for a GitHub Enterprise Server URL/domain. Leave it blank for `github.com`. Device login also works in SSH/headless sessions: open the -shown verification URL on any device and enter the displayed code. - -Anthropic uses distinct direct-login aliases so the authentication method is -unambiguous: `/login anthropic-subscription` starts OAuth, while -`/login anthropic-api` saves an API key. The top-level `/login` picker still -lists Anthropic under both **Subscription / OAuth** and **API key**. OAuth -subscription requests use Anthropic's required +shown verification URL on any device and enter the displayed code. xAI uses the +same device-code pattern: `/login xai` or `/login xai-subscription` shows a +verification URL and short code. `/login xai-api` saves an `XAI_API_KEY`. +The top-level `/login` picker still lists xAI under both **Subscription / OAuth** +and **API key**. + +Anthropic and xAI use distinct direct-login aliases so the authentication method +is unambiguous: `/login anthropic-subscription` and `/login xai-subscription` +start OAuth, while `/login anthropic-api` and `/login xai-api` save an API key. +`/login xai` defaults to the subscription device-code flow. The top-level +`/login` picker still lists both providers under **Subscription / OAuth** and +**API key**. + +Anthropic OAuth subscription requests use Anthropic's required Claude Code identity and may be billed as extra usage rather than consuming ordinary Claude plan limits. Check Anthropic's current account terms before using it. @@ -273,9 +283,9 @@ port is unavailable or the browser runs on another machine. In that flow the login screen copies the authorization URL to your clipboard and renders it as a link, so paste or click it rather than selecting the wrapped text — a URL reassembled by hand loses characters at the line breaks and the provider -rejects it. Copilot uses a device code instead: open the short verification -URL and enter the code shown beneath it. A denied or expired code requires a -new `/login`. If a Copilot model reports that it is unsupported, enable it in +rejects it. Copilot and xAI use a device code instead: open the short +verification URL and enter the code shown beneath it. A denied or expired code +requires a new `/login`. If a Copilot model reports that it is unsupported, enable it in Copilot Chat's model selector or ask your organization administrator; provider/model access varies by plan and policy. {{% /note %}} diff --git a/website/content/quickstart.md b/website/content/quickstart.md index 47920dfc6..1277615d0 100644 --- a/website/content/quickstart.md +++ b/website/content/quickstart.md @@ -83,6 +83,7 @@ Then run one of these inside Tau: /login # choose a provider /login openai # save an OpenAI API key /login openai-codex # authenticate a Codex/ChatGPT subscription +/login xai # authenticate SuperGrok/X Premium via device code ``` Tau ships with built-in entries for OpenAI, Anthropic, OpenAI Codex, diff --git a/website/content/reference/slash-commands.md b/website/content/reference/slash-commands.md index e592c3bd4..fee815928 100644 --- a/website/content/reference/slash-commands.md +++ b/website/content/reference/slash-commands.md @@ -21,7 +21,7 @@ command palette with **Ctrl+K**. | `/tools` | Browse active tools and open their full descriptions | | `/scoped-models` | Choose favorite models for the Ctrl+P / Shift+Ctrl+P quick-cycle | | `/theme [name]` | Show or set the TUI theme | -| `/login [provider]` | Connect a built-in provider with OAuth or an API key; Anthropic uses `anthropic-subscription` or `anthropic-api` | +| `/login [provider]` | Connect a built-in provider with OAuth or an API key; Anthropic uses `anthropic-subscription` or `anthropic-api`; xAI uses `xai`/`xai-subscription` or `xai-api` | | `/local` | Choose and manage a registered local backend; interactive-only. Compatible llama.cpp routers add explicit load/unload, Hugging Face GGUF search, and server-side download actions with confirmation and reconciliation. | | `/logout [provider]` | Remove saved credentials for a provider | | `/reload` | Reload local skills, prompts, extensions, and project context |