diff --git a/docs/providers/xai.md b/docs/providers/xai.md index f9e184bcf54f..bbfa291d7c1d 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -29,10 +29,11 @@ Use the path that matches your OpenClaw install state: openclaw onboard --install-daemon ``` - On a VPS or over SSH, use device-code during onboarding: + On a VPS or over SSH, select xAI OAuth directly; OpenClaw uses device-code + verification and does not require a localhost callback: ```bash - openclaw onboard --install-daemon --auth-choice xai-device-code + openclaw onboard --install-daemon --auth-choice xai-oauth ``` OAuth does not require an xAI API key. OpenClaw does not require the Grok @@ -48,13 +49,6 @@ Use the path that matches your OpenClaw install state: openclaw models auth login --provider xai --method oauth ``` - Use the device-code flow instead when the Gateway runs over SSH, Docker, or - a VPS and a localhost browser callback is awkward: - - ```bash - openclaw models auth login --provider xai --device-code - ``` - To make Grok the default model after signing in, apply it separately: ```bash @@ -86,8 +80,7 @@ Use the path that matches your OpenClaw install state: OpenClaw uses the xAI Responses API as the bundled xAI transport. The same -credential from `openclaw models auth login --provider xai --method oauth`, -`openclaw models auth login --provider xai --device-code`, or +credential from `openclaw models auth login --provider xai --method oauth` or `openclaw models auth login --provider xai --method api-key` can also power first-class `web_search`, `x_search`, remote `code_execution`, and xAI image/video generation. Speech and transcription currently require `XAI_API_KEY` or provider config. @@ -102,8 +95,9 @@ and, by default, `x_search` through an operator xAI Responses proxy. ## OAuth troubleshooting -- If browser OAuth cannot reach `127.0.0.1:56121`, use - `openclaw models auth login --provider xai --device-code`. +- For SSH, Docker, VPS, or other remote setups, use + `openclaw models auth login --provider xai --method oauth`; xAI OAuth uses + device-code verification instead of a localhost callback. - If sign-in succeeds but Grok is not the default model, run `openclaw models set xai/grok-4.3`. - To inspect saved xAI auth profiles, run: @@ -117,9 +111,9 @@ and, by default, `x_search` through an operator xAI Responses proxy. eligible, try the API-key path or check the subscription on xAI's side. -Use `xai-device-code` when signing in from SSH, Docker, or a VPS. OpenClaw -prints an xAI URL and short code; finish sign-in in any local browser while the -remote process polls xAI for the completed token exchange. +Use `xai-oauth` when signing in from SSH, Docker, or a VPS. OpenClaw prints an +xAI URL and short code; finish sign-in in any local browser while the remote +process polls xAI for the completed token exchange. ## Built-in catalog @@ -498,12 +492,10 @@ Legacy aliases still normalize to the canonical bundled ids: - xAI auth can use an API key, environment variable, plugin config fallback, - browser OAuth, or device-code OAuth with an eligible xAI account. Browser - OAuth uses a local callback on `127.0.0.1:56121`; for remote hosts, use - `xai-device-code` unless you want to forward that port before opening the - sign-in URL. xAI decides which accounts can receive OAuth API tokens, and - the consent page may show Grok Build even though OpenClaw does not require - the Grok Build app. + or OAuth with an eligible xAI account. OAuth uses device-code verification + without a localhost callback. xAI decides which accounts can receive OAuth + API tokens, and the consent page may show Grok Build even though OpenClaw + does not require the Grok Build app. - OpenClaw does not currently expose the xAI multi-agent model family. xAI serves these models through the Responses API, but they do not accept the client-side or custom tools used by OpenClaw's shared agent loop. See the diff --git a/docs/tools/code-execution.md b/docs/tools/code-execution.md index 6d0ca2e6b594..488d17acd17a 100644 --- a/docs/tools/code-execution.md +++ b/docs/tools/code-execution.md @@ -38,13 +38,13 @@ Do **not** use it when you need local files, your shell, your repo, or paired de Sign in with Grok OAuth using an eligible SuperGrok or X Premium subscription, - use the remote-friendly device-code flow, or store an API key. OAuth works - for `code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search - config can also power Grok `web_search`. + or store an API key. xAI OAuth uses device-code verification, so it works + from remote hosts without a localhost callback. OAuth works for + `code_execution` and `x_search`; `XAI_API_KEY` or plugin web-search config + can also power Grok `web_search`. ```bash openclaw models auth login --provider xai --method oauth - openclaw models auth login --provider xai --device-code ``` During a fresh install, the same auth choices are available inside @@ -52,7 +52,7 @@ Do **not** use it when you need local files, your shell, your repo, or paired de ```bash openclaw onboard --install-daemon - openclaw onboard --install-daemon --auth-choice xai-device-code + openclaw onboard --install-daemon --auth-choice xai-oauth ``` Or use an API key: diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index 3158e4b32483..2c48bc708fac 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -18,6 +18,7 @@ const providerAuthRuntimeMocks = vi.hoisted(() => ({ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMocks); import plugin from "./index.js"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildLiveXaiProvider } from "./provider-catalog.js"; import setupPlugin from "./setup-api.js"; import { @@ -82,13 +83,24 @@ describe("xai provider plugin", () => { vi.unstubAllGlobals(); }); - it("exposes OAuth and device-code auth choices", async () => { + it("exposes xAI OAuth and preserves the explicit device-code alias", async () => { const provider = await registerSingleProviderPlugin(plugin); expect(provider.auth?.map((method) => method.id)).toEqual(["api-key", "oauth", "device-code"]); + const oauth = provider.auth?.find((method) => method.id === "oauth"); + expect(oauth?.kind).toBe("oauth"); + expect(oauth?.wizard?.choiceId).toBe("xai-oauth"); const deviceCode = provider.auth?.find((method) => method.id === "device-code"); expect(deviceCode?.kind).toBe("device_code"); expect(deviceCode?.wizard?.choiceId).toBe("xai-device-code"); + expect(deviceCode?.wizard?.assistantVisibility).toBe("manual-only"); + expect(manifest.providerAuthChoices).toContainEqual( + expect.objectContaining({ + assistantVisibility: "manual-only", + choiceId: "xai-device-code", + method: "device-code", + }), + ); }); it("filters the xAI API-key catalog against live model ids", async () => { diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 9f28d1f10109..90652d5802a6 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -87,7 +87,7 @@ "choiceLabel": "xAI API key", "groupId": "xai", "groupLabel": "xAI (Grok)", - "groupHint": "API key or browser OAuth", + "groupHint": "API key or OAuth", "onboardingFeatured": true, "optionKey": "xaiApiKey", "cliFlag": "--xai-api-key", @@ -99,10 +99,10 @@ "method": "oauth", "choiceId": "xai-oauth", "choiceLabel": "xAI OAuth", - "choiceHint": "Browser sign-in for eligible xAI accounts", + "choiceHint": "Remote-friendly browser sign-in without a localhost callback", "groupId": "xai", "groupLabel": "xAI (Grok)", - "groupHint": "API key or browser OAuth", + "groupHint": "API key or OAuth", "onboardingFeatured": true }, { @@ -110,11 +110,11 @@ "method": "device-code", "choiceId": "xai-device-code", "choiceLabel": "xAI device code", - "choiceHint": "Remote-friendly browser sign-in without a localhost callback", + "choiceHint": "Compatibility alias for xAI OAuth device-code sign-in", + "assistantVisibility": "manual-only", "groupId": "xai", "groupLabel": "xAI (Grok)", - "groupHint": "API key or browser OAuth", - "onboardingFeatured": true + "groupHint": "API key or OAuth" } ], "uiHints": { diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index d69779e4472a..1c747748de5f 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -5,28 +5,16 @@ import { createTestWizardPrompter, } from "openclaw/plugin-sdk/plugin-test-runtime"; import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const waitForLocalOAuthCallbackMock = vi.hoisted(() => vi.fn()); - -vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ - waitForLocalOAuthCallback: waitForLocalOAuthCallbackMock, -})); - +import { afterEach, describe, expect, it, vi } from "vitest"; import { - buildXaiOAuthAuthorizationCodeTokenBody, - buildXaiOAuthAuthorizeUrl, + createXaiDeviceCodeAuthMethod, + createXaiOAuthAuthMethod, fetchXaiOAuthDiscovery, isTrustedXaiOAuthEndpoint, loginXaiDeviceCode, - loginXaiOAuth, refreshXaiOAuthCredential, - XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST, - XAI_OAUTH_CALLBACK_HOST, - XAI_OAUTH_CALLBACK_PORT, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_DISCOVERY_URL, - XAI_OAUTH_REDIRECT_URI, XAI_OAUTH_SCOPE, } from "./xai-oauth.js"; @@ -61,32 +49,7 @@ function requestUrl(input: RequestInfo | URL): string { return input.url; } -function stubSuccessfulXaiOAuthNetwork(): void { - const fetchImpl = vi.fn(async (url, init) => { - if (requestUrl(url) === XAI_OAUTH_DISCOVERY_URL) { - return jsonResponse({ - authorization_endpoint: "https://auth.x.ai/oauth2/authorize", - token_endpoint: "https://auth.x.ai/oauth2/token", - }); - } - - expect(requestUrl(url)).toBe("https://auth.x.ai/oauth2/token"); - expect(init?.method).toBe("POST"); - expect(requireStringBody(init)).toContain("code=AUTHCODE"); - return jsonResponse({ - access_token: "access-token", - refresh_token: "refresh-token", - expires_in: 3600, - }); - }); - vi.stubGlobal("fetch", fetchImpl); -} - describe("xAI OAuth", () => { - beforeEach(() => { - waitForLocalOAuthCallbackMock.mockReset(); - }); - afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); @@ -101,50 +64,23 @@ describe("xAI OAuth", () => { expect(isTrustedXaiOAuthEndpoint("not a url")).toBe(false); }); - it("exposes the loopback CORS origin allowlist that loginXaiOAuth threads to the SDK helper", () => { - expect([...XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST]).toEqual(["auth.x.ai", "accounts.x.ai"]); + it("keeps the public auth method named OAuth while using device code", () => { + const method = createXaiOAuthAuthMethod(); + + expect(method.id).toBe("oauth"); + expect(method.kind).toBe("oauth"); + expect(method.wizard?.choiceId).toBe("xai-oauth"); + expect(method.wizard?.methodId).toBe("oauth"); }); - it("builds the xAI authorize URL for OpenClaw", () => { - const url = new URL( - buildXaiOAuthAuthorizeUrl({ - authorizationEndpoint: "https://auth.x.ai/oauth2/authorize", - state: "state-1", - nonce: "nonce-1", - challenge: "challenge-1", - }), - ); + it("preserves device-code as an explicit auth method alias", () => { + const method = createXaiDeviceCodeAuthMethod(); - expect(url.origin + url.pathname).toBe("https://auth.x.ai/oauth2/authorize"); - expect(url.searchParams.get("response_type")).toBe("code"); - expect(url.searchParams.get("client_id")).toBe(XAI_OAUTH_CLIENT_ID); - expect(url.searchParams.get("redirect_uri")).toBe(XAI_OAUTH_REDIRECT_URI); - expect(url.searchParams.get("scope")).toBe(XAI_OAUTH_SCOPE); - expect(url.searchParams.get("code_challenge")).toBe("challenge-1"); - expect(url.searchParams.get("code_challenge_method")).toBe("S256"); - expect(url.searchParams.get("state")).toBe("state-1"); - expect(url.searchParams.get("nonce")).toBe("nonce-1"); - expect(url.searchParams.get("plan")).toBe("generic"); - expect(url.searchParams.get("referrer")).toBe("openclaw"); - expect(XAI_OAUTH_REDIRECT_URI).toContain(`:${XAI_OAUTH_CALLBACK_PORT}/`); - }); - - it("echoes PKCE challenge fields when exchanging authorization codes with xAI", () => { - expect( - buildXaiOAuthAuthorizationCodeTokenBody({ - code: "AUTHCODE", - codeVerifier: "verifier-1", - codeChallenge: "challenge-1", - }), - ).toEqual({ - grant_type: "authorization_code", - code: "AUTHCODE", - redirect_uri: XAI_OAUTH_REDIRECT_URI, - client_id: XAI_OAUTH_CLIENT_ID, - code_verifier: "verifier-1", - code_challenge: "challenge-1", - code_challenge_method: "S256", - }); + expect(method.id).toBe("device-code"); + expect(method.kind).toBe("device_code"); + expect(method.wizard?.choiceId).toBe("xai-device-code"); + expect(method.wizard?.methodId).toBe("device-code"); + expect(method.wizard?.assistantVisibility).toBe("manual-only"); }); it("validates discovered endpoints before using them", async () => { @@ -157,7 +93,6 @@ describe("xAI OAuth", () => { ); await expect(fetchXaiOAuthDiscovery({ fetchImpl })).resolves.toEqual({ - authorizationEndpoint: "https://auth.x.ai/oauth2/authorize", tokenEndpoint: "https://auth.x.ai/oauth2/token", }); @@ -272,6 +207,156 @@ describe("xAI OAuth", () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + it("retries transient HTML refresh failures before succeeding", async () => { + vi.useFakeTimers(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response("Attention Required! Cloudflare", { + status: 403, + headers: { + "Content-Type": "text/html", + "cf-mitigated": "challenge", + }, + }), + ) + .mockResolvedValueOnce( + new Response("Just a moment...", { + status: 403, + headers: { + "Content-Type": "text/html", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + access_token: "access-2", + expires_in: 120, + }), + ); + const credential = { + type: "oauth", + provider: "xai", + access: "access-1", + refresh: "refresh-1", + expires: 100, + tokenEndpoint: "https://auth.x.ai/oauth2/token", + } satisfies OAuthCredential & { tokenEndpoint: string }; + + const refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 }); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(250); + const refreshed = await refresh; + + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(refreshed.access).toBe("access-2"); + expect(refreshed.refresh).toBe("refresh-1"); + }); + + it("surfaces xAI Cloudflare refresh failures after retry exhaustion", async () => { + vi.useFakeTimers(); + const fetchImpl = vi.fn( + async () => + new Response( + "Attention Required! | CloudflareYou are unable to access x.ai", + { + status: 403, + headers: { + "Content-Type": "text/html", + "cf-mitigated": "challenge", + }, + }, + ), + ); + const credential = { + type: "oauth", + provider: "xai", + access: "access-1", + refresh: "refresh-1", + expires: 100, + tokenEndpoint: "https://auth.x.ai/oauth2/token", + } satisfies OAuthCredential & { tokenEndpoint: string }; + + const refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 }); + const expectation = expect(refresh).rejects.toThrow( + "xAI returned an HTML/Cloudflare challenge", + ); + await vi.advanceTimersByTimeAsync(250); + await vi.advanceTimersByTimeAsync(250); + + await expectation; + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("does not retry terminal xAI OAuth refresh errors", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse( + { + error: "invalid_grant", + error_description: "Invalid or unknown refresh token", + }, + { status: 400 }, + ), + ); + const credential = { + type: "oauth", + provider: "xai", + access: "access-1", + refresh: "refresh-1", + expires: 100, + tokenEndpoint: "https://auth.x.ai/oauth2/token", + } satisfies OAuthCredential & { tokenEndpoint: string }; + + await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow( + "invalid_grant (Invalid or unknown refresh token)", + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("does not retry refresh-token service failures", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse( + { + error: "server_error", + error_description: "try again later", + }, + { status: 503 }, + ), + ); + const credential = { + type: "oauth", + provider: "xai", + access: "access-1", + refresh: "refresh-1", + expires: 100, + tokenEndpoint: "https://auth.x.ai/oauth2/token", + } satisfies OAuthCredential & { tokenEndpoint: string }; + + await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow( + "server_error (try again later)", + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("does not retry refresh on transport errors so a rotated refresh token is never resent", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("socket hang up"); + }); + const credential = { + type: "oauth", + provider: "xai", + access: "access-1", + refresh: "refresh-1", + expires: 100, + tokenEndpoint: "https://auth.x.ai/oauth2/token", + } satisfies OAuthCredential & { tokenEndpoint: string }; + + await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow( + "socket hang up", + ); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("does not coerce partial xAI expires_in values", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ @@ -334,85 +419,6 @@ describe("xAI OAuth", () => { expect(refreshed.expires).toBe(100); }); - it("prints the authorize URL through plain prompter output so terminal link detection keeps it whole", async () => { - waitForLocalOAuthCallbackMock.mockResolvedValue({ code: "AUTHCODE", state: "state-1" }); - stubSuccessfulXaiOAuthNetwork(); - - const progress = { update: vi.fn(), stop: vi.fn() }; - const note = vi.fn<(message: string, title?: string) => Promise>(async () => undefined); - const plain = vi.fn<(message: string) => Promise>(async () => undefined); - const openUrl = vi.fn<(url: string) => Promise>(async () => undefined); - const runtimeLog = vi.fn<(message: string) => void>(); - const ctx = { - config: {}, - isRemote: true, - openUrl, - prompter: { - note, - plain, - progress: vi.fn(() => progress), - }, - runtime: { - log: runtimeLog, - error: vi.fn(), - exit: vi.fn(), - }, - oauth: { createVpsAwareHandlers: vi.fn() }, - } as unknown as ProviderAuthContext; - - await loginXaiOAuth(ctx); - - expect(openUrl).not.toHaveBeenCalled(); - const noteMessage = note.mock.calls[0]?.[0] ?? ""; - expect(noteMessage).toContain("Open this xAI OAuth URL in your browser:"); - expect(noteMessage).toContain( - `ssh -N -L ${XAI_OAUTH_CALLBACK_PORT}:${XAI_OAUTH_CALLBACK_HOST}:${XAI_OAUTH_CALLBACK_PORT} `, - ); - expect(noteMessage).not.toContain("https://auth.x.ai/oauth2/authorize"); - - const plainOutput = plain.mock.calls[0]?.[0] ?? ""; - expect(plainOutput.trim()).toMatch(/^https:\/\/auth\.x\.ai\/oauth2\/authorize\?/); - expect(plainOutput).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`); - expect(plainOutput).toContain("code_challenge="); - expect(runtimeLog).not.toHaveBeenCalled(); - expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete"); - }); - - it("keeps the authorize URL visible for prompters without plain output", async () => { - waitForLocalOAuthCallbackMock.mockResolvedValue({ code: "AUTHCODE", state: "state-1" }); - stubSuccessfulXaiOAuthNetwork(); - - const progress = { update: vi.fn(), stop: vi.fn() }; - const note = vi.fn<(message: string, title?: string) => Promise>(async () => undefined); - const openUrl = vi.fn<(url: string) => Promise>(async () => undefined); - const runtimeLog = vi.fn<(message: string) => void>(); - const ctx = { - config: {}, - isRemote: false, - openUrl, - prompter: { - note, - progress: vi.fn(() => progress), - }, - runtime: { - log: runtimeLog, - error: vi.fn(), - exit: vi.fn(), - }, - oauth: { createVpsAwareHandlers: vi.fn() }, - } as unknown as ProviderAuthContext; - - await loginXaiOAuth(ctx); - - const authorizeUrl = openUrl.mock.calls[0]?.[0] ?? ""; - const noteMessage = note.mock.calls[0]?.[0] ?? ""; - expect(authorizeUrl).toContain("https://auth.x.ai/oauth2/authorize?"); - expect(noteMessage).toContain("Open this xAI OAuth URL in your browser:"); - expect(noteMessage).not.toContain(authorizeUrl); - expect(runtimeLog.mock.calls[0]?.[0] ?? "").toContain(authorizeUrl); - expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete"); - }); - it("logs in with xAI device code without a localhost callback", async () => { vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); const progress = { @@ -474,7 +480,7 @@ describe("xAI OAuth", () => { const result = await loginXaiDeviceCode(ctx); expect(openUrl).not.toHaveBeenCalled(); - expect(note).toHaveBeenCalledWith(expect.stringContaining("ABCD-1234"), "xAI device code"); + expect(note).toHaveBeenCalledWith(expect.stringContaining("ABCD-1234"), "xAI OAuth"); const remoteLog = log.mock.calls[0]?.[0]; expect(remoteLog).toContain("https://accounts.x.ai/oauth2/device"); expect(remoteLog).not.toContain("ABCD-1234"); @@ -506,7 +512,7 @@ describe("xAI OAuth", () => { access: expect.any(String), }); expect(progress.update).toHaveBeenCalledWith("Waiting for xAI device authorization..."); - expect(progress.stop).toHaveBeenCalledWith("xAI device code complete"); + expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete"); }); it("falls back for unsafe xAI device-code lifetime fields", async () => { @@ -561,8 +567,8 @@ describe("xAI OAuth", () => { expect(note).toHaveBeenCalledWith( expect.stringContaining("Code expires in 5 minutes."), - "xAI device code", + "xAI OAuth", ); - expect(progress.stop).toHaveBeenCalledWith("xAI device code complete"); + expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete"); }); }); diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index 7a1c4674b6a6..5d52f82372cb 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -1,5 +1,4 @@ // Xai plugin module implements xai oauth behavior. -import { randomBytes } from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { positiveSecondsToSafeMilliseconds, @@ -9,12 +8,10 @@ import { import type { ProviderAuthContext, ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; import { buildOauthProviderAuthResult, - generateHexPkceVerifierChallenge, toFormUrlEncoded, type OAuthCredential, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; -import { waitForLocalOAuthCallback } from "openclaw/plugin-sdk/provider-auth-runtime"; import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; import { xaiUserAgent } from "./src/xai-user-agent.js"; @@ -28,23 +25,17 @@ export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:acc export const XAI_OAUTH_ISSUER = "https://auth.x.ai"; export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`; const XAI_LEGACY_OAUTH_TOKEN_ENDPOINT = `${XAI_OAUTH_ISSUER}/oauth/token`; -export const XAI_OAUTH_CALLBACK_HOST = "127.0.0.1"; -export const XAI_OAUTH_CALLBACK_PORT = 56121; -export const XAI_OAUTH_CALLBACK_PATH = "/callback"; -export const XAI_OAUTH_REDIRECT_URI = `http://${XAI_OAUTH_CALLBACK_HOST}:${XAI_OAUTH_CALLBACK_PORT}${XAI_OAUTH_CALLBACK_PATH}`; -// Hosts whose CORS preflight against the loopback redirect URI should be -// echoed; everything else gets a 204 with no `Access-Control-Allow-*`. -export const XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST = ["auth.x.ai", "accounts.x.ai"] as const; const XAI_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; const XAI_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000; +const XAI_OAUTH_REFRESH_MAX_ATTEMPTS = 3; +const XAI_OAUTH_REFRESH_RETRY_DELAY_MS = 250; const XAI_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5 * 1000; const XAI_DEVICE_CODE_MIN_INTERVAL_MS = 1 * 1000; const XAI_DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5 * 1000; const XAI_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; type XaiOAuthDiscovery = { - authorizationEndpoint: string; tokenEndpoint: string; }; @@ -85,6 +76,11 @@ type XaiOAuthErrorResponse = { errorDescription?: string; }; +type XaiOAuthResponseBody = { + json: unknown; + text: string; +}; + function getFetchImpl(fetchImpl?: typeof fetch): typeof fetch { return fetchImpl ?? fetch; } @@ -114,20 +110,27 @@ function readStringRecord(value: unknown): Record { : {}; } -async function readJsonResponse(response: Response, context: string): Promise { - let body: unknown; +async function readResponseBody(response: Response): Promise { + const text = await response.text(); + let json: unknown; try { - body = await response.json(); + json = JSON.parse(text); } catch { - body = null; + json = null; } + return { json, text }; +} + +async function readJsonResponse(response: Response, context: string): Promise { + const body = await readResponseBody(response); if (!response.ok) { - const errorText = readStringRecord(body).error_description ?? readStringRecord(body).error; + const errorText = + readStringRecord(body.json).error_description ?? readStringRecord(body.json).error; throw new Error( `${context} failed (${response.status})${typeof errorText === "string" ? `: ${errorText}` : ""}`, ); } - return body; + return body.json; } async function fetchXaiOAuthDiscoveryDocument( @@ -147,16 +150,11 @@ export async function fetchXaiOAuthDiscovery( options: XaiOAuthFetchOptions = {}, ): Promise { const json = await fetchXaiOAuthDiscoveryDocument(options); - const authorizationEndpoint = json.authorization_endpoint; const tokenEndpoint = json.token_endpoint; - if (typeof authorizationEndpoint !== "string" || typeof tokenEndpoint !== "string") { - throw new Error("xAI OAuth discovery response is missing endpoints"); + if (typeof tokenEndpoint !== "string") { + throw new Error("xAI OAuth discovery response is missing the token endpoint"); } return { - authorizationEndpoint: requireTrustedXaiOAuthEndpoint( - authorizationEndpoint, - "authorization endpoint", - ), tokenEndpoint: requireTrustedXaiOAuthEndpoint(tokenEndpoint, "token endpoint"), }; } @@ -179,45 +177,6 @@ async function fetchXaiDeviceCodeDiscovery( }; } -export function buildXaiOAuthAuthorizeUrl(params: { - authorizationEndpoint: string; - state: string; - nonce: string; - challenge: string; -}): string { - const url = new URL( - requireTrustedXaiOAuthEndpoint(params.authorizationEndpoint, "authorization endpoint"), - ); - url.searchParams.set("response_type", "code"); - url.searchParams.set("client_id", XAI_OAUTH_CLIENT_ID); - url.searchParams.set("redirect_uri", XAI_OAUTH_REDIRECT_URI); - url.searchParams.set("scope", XAI_OAUTH_SCOPE); - url.searchParams.set("state", params.state); - url.searchParams.set("nonce", params.nonce); - url.searchParams.set("code_challenge", params.challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("plan", "generic"); - url.searchParams.set("referrer", "openclaw"); - return url.toString(); -} - -export function buildXaiOAuthAuthorizationCodeTokenBody(params: { - code: string; - codeVerifier: string; - codeChallenge: string; -}): Record { - return { - grant_type: "authorization_code", - code: params.code, - redirect_uri: XAI_OAUTH_REDIRECT_URI, - client_id: XAI_OAUTH_CLIENT_ID, - code_verifier: params.codeVerifier, - // xAI validates these PKCE fields again at token exchange for this client. - code_challenge: params.codeChallenge, - code_challenge_method: "S256", - }; -} - function normalizeExpires(value: unknown, now: () => number): number | undefined { return resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now() }); } @@ -288,6 +247,59 @@ function formatXaiOAuthError(params: { context: string; status: number; body: un return `${params.context} failed (${params.status})`; } +function isLikelyXaiCloudflareChallenge(params: { response: Response; bodyText: string }): boolean { + const contentType = params.response.headers.get("content-type") ?? ""; + return ( + params.response.headers.get("cf-mitigated") === "challenge" || + /text\/html/i.test(contentType) || + / { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + async function exchangeXaiOAuthToken( params: { tokenEndpoint: string; @@ -296,24 +308,46 @@ async function exchangeXaiOAuthToken( requireRefreshToken?: boolean; } & XaiOAuthFetchOptions, ): Promise { - const response = await getFetchImpl(params.fetchImpl)( - requireTrustedXaiOAuthEndpoint(params.tokenEndpoint, "token endpoint"), - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": xaiUserAgent(), - }, - body: toFormUrlEncoded(params.body), - signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS), - }, - ); - return parseXaiOAuthTokenResponse( - await readJsonResponse(response, params.context), - params.now ?? Date.now, - { requireRefreshToken: params.requireRefreshToken }, - ); + const endpoint = requireTrustedXaiOAuthEndpoint(params.tokenEndpoint, "token endpoint"); + const maxAttempts = + params.body.grant_type === "refresh_token" ? XAI_OAUTH_REFRESH_MAX_ATTEMPTS : 1; + let lastMessage = `${params.context} failed`; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let response: Response; + try { + response = await getFetchImpl(params.fetchImpl)(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": xaiUserAgent(), + }, + body: toFormUrlEncoded(params.body), + signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS), + }); + } catch (err) { + // Transport failures are not safe to retry for refresh grants: xAI rotates + // refresh tokens, so a response lost after xAI consumed the token would burn + // it on resend. Only Cloudflare challenge responses are retried below. + throw new Error(`${params.context} failed: ${formatErrorMessage(err)}`, { cause: err }); + } + const body = await readResponseBody(response); + if (response.ok) { + return parseXaiOAuthTokenResponse(body.json, params.now ?? Date.now, { + requireRefreshToken: params.requireRefreshToken, + }); + } + + const failure = describeXaiOAuthTokenFailure({ context: params.context, response, body }); + lastMessage = failure.message; + if (attempt >= maxAttempts || !failure.retryable) { + throw new Error(lastMessage); + } + await sleep(XAI_OAUTH_REFRESH_RETRY_DELAY_MS); + } + + throw new Error(lastMessage); } async function requestXaiDeviceCode( @@ -493,113 +527,27 @@ function readCredentialString( return typeof value === "string" && value.trim().length > 0 ? value : undefined; } +function isLegacyXaiOAuthTokenEndpoint(endpoint: string): boolean { + try { + const url = new URL(endpoint); + return `${url.origin}${url.pathname}` === XAI_LEGACY_OAUTH_TOKEN_ENDPOINT; + } catch { + return false; + } +} + async function resolveXaiOAuthRefreshTokenEndpoint( credential: OAuthCredential, options: XaiOAuthFetchOptions, ): Promise { const cachedEndpoint = readCredentialString(credential, "tokenEndpoint"); - if (!cachedEndpoint) { + // Rediscover when there is no cached endpoint, or when an older persisted + // credential still points at the retired endpoint, so refresh writes back the + // current OAuth token endpoint. + if (!cachedEndpoint || isLegacyXaiOAuthTokenEndpoint(cachedEndpoint)) { return (await fetchXaiOAuthDiscovery(options)).tokenEndpoint; } - let endpoint: URL; - try { - endpoint = new URL(cachedEndpoint); - } catch { - return cachedEndpoint; - } - if (`${endpoint.origin}${endpoint.pathname}` !== XAI_LEGACY_OAUTH_TOKEN_ENDPOINT) { - return cachedEndpoint; - } - // Older persisted xAI OAuth credentials can point at the retired endpoint; - // rediscover once so refresh writes back the current OAuth token endpoint. - return (await fetchXaiOAuthDiscovery(options)).tokenEndpoint; -} - -async function noteXaiOAuthUrl(ctx: ProviderAuthContext, authorizeUrl: string): Promise { - const lines = ["Open this xAI OAuth URL in your browser:"]; - if (ctx.isRemote) { - lines.push( - "", - "Remote host: forward the callback before signing in:", - `ssh -N -L ${XAI_OAUTH_CALLBACK_PORT}:${XAI_OAUTH_CALLBACK_HOST}:${XAI_OAUTH_CALLBACK_PORT} `, - ); - } - await ctx.prompter.note(lines.join("\n"), "xAI OAuth"); - if (ctx.prompter.plain) { - await ctx.prompter.plain(`\n${authorizeUrl}\n`); - return; - } - ctx.runtime.log(`\n${authorizeUrl}\n`); -} - -export async function loginXaiOAuth(ctx: ProviderAuthContext): Promise { - const progress = ctx.prompter.progress("Starting xAI OAuth..."); - try { - const discovery = await fetchXaiOAuthDiscovery(); - const pkce = generateHexPkceVerifierChallenge(); - const state = randomBytes(32).toString("hex"); - const nonce = randomBytes(16).toString("hex"); - const authorizeUrl = buildXaiOAuthAuthorizeUrl({ - authorizationEndpoint: discovery.authorizationEndpoint, - state, - nonce, - challenge: pkce.challenge, - }); - progress.update(`Waiting for xAI OAuth callback on ${XAI_OAUTH_REDIRECT_URI}...`); - const callbackPromise = waitForLocalOAuthCallback({ - expectedState: state, - timeoutMs: XAI_OAUTH_TIMEOUT_MS, - port: XAI_OAUTH_CALLBACK_PORT, - callbackPath: XAI_OAUTH_CALLBACK_PATH, - redirectUri: XAI_OAUTH_REDIRECT_URI, - hostname: XAI_OAUTH_CALLBACK_HOST, - successTitle: "xAI OAuth complete", - onProgress: (message) => progress.update(message), - corsOriginAllowlist: XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST, - }); - void callbackPromise.catch(() => undefined); - await noteXaiOAuthUrl(ctx, authorizeUrl); - if (!ctx.isRemote) { - await ctx.openUrl(authorizeUrl); - } - const callback = await callbackPromise; - const tokens = await exchangeXaiOAuthToken({ - tokenEndpoint: discovery.tokenEndpoint, - context: "xAI OAuth token exchange", - requireRefreshToken: true, - body: buildXaiOAuthAuthorizationCodeTokenBody({ - code: callback.code, - codeVerifier: pkce.verifier, - codeChallenge: pkce.challenge, - }), - }); - const identity = resolveXaiOAuthIdentity(tokens); - progress.stop("xAI OAuth complete"); - return buildOauthProviderAuthResult({ - providerId: PROVIDER_ID, - defaultModel: XAI_DEFAULT_MODEL_REF, - access: tokens.accessToken, - refresh: tokens.refreshToken, - expires: tokens.expires, - email: identity.email, - displayName: identity.displayName, - profileName: identity.email ?? identity.accountId, - configPatch: applyXaiConfig(ctx.config), - credentialExtra: { - tokenEndpoint: discovery.tokenEndpoint, - issuer: XAI_OAUTH_ISSUER, - ...(tokens.idToken ? { idToken: tokens.idToken } : {}), - ...(identity.accountId ? { accountId: identity.accountId } : {}), - }, - notes: [ - "xAI OAuth uses your xAI account entitlement; xAI API keys still work.", - "xAI may label the consent app as Grok Build because OpenClaw uses xAI's shared OAuth client.", - ], - }); - } catch (err) { - progress.stop("xAI OAuth failed"); - throw new Error(`xAI OAuth failed: ${formatErrorMessage(err)}`, { cause: err }); - } + return cachedEndpoint; } async function noteXaiDeviceCode( @@ -616,15 +564,15 @@ async function noteXaiDeviceCode( `Code: ${deviceCode.userCode}`, `Code expires in ${expiresInMinutes} minutes. Never share it.`, ].join("\n"), - "xAI device code", + "xAI OAuth", ); } export async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise { - const progress = ctx.prompter.progress("Starting xAI device code flow..."); + const progress = ctx.prompter.progress("Starting xAI OAuth..."); try { const discovery = await fetchXaiDeviceCodeDiscovery(); - progress.update("Requesting xAI device code..."); + progress.update("Requesting xAI OAuth device code..."); const deviceCode = await requestXaiDeviceCode({ deviceAuthorizationEndpoint: discovery.deviceAuthorizationEndpoint, }); @@ -650,7 +598,7 @@ export async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise loginXaiOAuth(ctx), + run: async (ctx) => loginXaiDeviceCode(ctx), }; } @@ -739,15 +687,16 @@ export function createXaiDeviceCodeAuthMethod(): ProviderAuthMethod { return { id: XAI_DEVICE_CODE_METHOD_ID, label: "xAI device code", - hint: "Remote-friendly browser sign-in without a localhost callback", + hint: "Deprecated alias for xAI OAuth device-code login", kind: "device_code", wizard: { choiceId: XAI_DEVICE_CODE_CHOICE_ID, choiceLabel: "xAI device code", - choiceHint: "Remote-friendly browser sign-in without a localhost callback", + choiceHint: "Compatibility alias for xAI OAuth device-code sign-in", + assistantVisibility: "manual-only", groupId: PROVIDER_ID, groupLabel: "xAI (Grok)", - groupHint: "API key or browser OAuth", + groupHint: "API key or OAuth", methodId: XAI_DEVICE_CODE_METHOD_ID, }, run: async (ctx) => loginXaiDeviceCode(ctx),