diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index a9a5067677..e59fefe080 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -19,6 +19,7 @@ import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { AzureAuthPlugin } from "./azure" import { DigitalOceanAuthPlugin } from "./digitalocean" +import { XaiAuthPlugin } from "./xai" import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" @@ -66,6 +67,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [ CloudflareAIGatewayAuthPlugin, AzureAuthPlugin, DigitalOceanAuthPlugin, + XaiAuthPlugin, ] function isServerPlugin(value: unknown): value is PluginInstance { diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts new file mode 100644 index 0000000000..73612d8528 --- /dev/null +++ b/packages/opencode/src/plugin/xai.ts @@ -0,0 +1,739 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import * as Log from "@opencode-ai/core/util/log" +import { OAUTH_DUMMY_KEY } from "../auth" +import { createServer } from "http" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +const log = Log.create({ service: "plugin.xai" }) + +// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from +// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships +// for desktop OAuth flows. Source of truth: hermes-agent PR #26534. +const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" +const AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize" +const TOKEN_URL = "https://auth.x.ai/oauth2/token" +// RFC 8628 device authorization grant. Confirmed exposed by xAI's +// /.well-known/openid-configuration as `device_authorization_endpoint` +// with the matching `urn:ietf:params:oauth:grant-type:device_code` grant +// in `grant_types_supported`. This is the headless / VPS path: no +// loopback callback server, no SSH port forwarding, no inbound firewall +// holes — the user opens the URL on any device with a browser, types +// the short user_code, and the CLI long-polls the token endpoint. +const DEVICE_AUTHORIZATION_URL = "https://auth.x.ai/oauth2/device/code" +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" +const SCOPE = "openid profile email offline_access grok-cli:access api:access" + +// Bounds for the device-code poll loop. xAI returns `interval` (seconds) +// but we floor it to avoid hammering and we add the spec's slow_down +// increment when xAI explicitly asks us to back off. +const DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000 +const DEVICE_CODE_MIN_INTERVAL_MS = 1_000 +const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000 +const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000 +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000 + +// xAI rejects redirect_uris that don't match what was registered for the +// Grok-CLI client. The host:port pair is part of the registration, so we have +// to bind the loopback server to this exact port. +const OAUTH_HOST = "127.0.0.1" +const OAUTH_PORT = 56121 +const OAUTH_REDIRECT_PATH = "/callback" +const REDIRECT_URI = `http://${OAUTH_HOST}:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}` + +// Refresh the access token a little before it actually expires so a single +// long-running tool call doesn't have to recover from a mid-flight 401. +const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000 + +interface XaiAuthPluginOptions { + authorizeUrl?: string + tokenUrl?: string + deviceAuthorizationUrl?: string +} + +interface PkceCodes { + verifier: string + challenge: string +} + +async function generatePKCE(): Promise { + const verifier = generateRandomString(64) + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) + return { verifier, challenge: base64UrlEncode(hash) } +} + +function generateRandomString(length: number): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + return Array.from(crypto.getRandomValues(new Uint8Array(length))) + .map((b) => chars[b % chars.length]) + .join("") +} + +function base64UrlEncode(buffer: ArrayBuffer): string { + const binary = String.fromCharCode(...new Uint8Array(buffer)) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function generateState(): string { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&" + case "<": + return "<" + case ">": + return ">" + case '"': + return """ + case "'": + return "'" + default: + return char + } + }) +} + +interface TokenResponse { + access_token: string + refresh_token: string + id_token?: string + token_type?: string + expires_in?: number + scope?: string +} + +function authHeaders() { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +// Parse the `exp` claim out of a JWT access_token without verifying the +// signature. We only use this to decide whether to proactively refresh, never +// to make trust decisions, so unsigned decode is safe. Returns false for +// opaque tokens (no JWT shape), which conservatively skips the proactive +// refresh and lets the 401-on-call path drive the refresh instead. +export function accessTokenIsExpiring(token: string | undefined, skewMs: number = ACCESS_TOKEN_REFRESH_SKEW_MS): boolean { + if (!token || typeof token !== "string") return false + const parts = token.split(".") + if (parts.length < 2) return false + try { + let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/") + while (payload.length % 4 !== 0) payload += "=" + const claims = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) + if (typeof claims?.exp !== "number") return false + return claims.exp * 1000 <= Date.now() + Math.max(0, skewMs) + } catch { + return false + } +} + +export function buildAuthorizeUrl( + pkce: PkceCodes, + state: string, + nonce: string, + options: XaiAuthPluginOptions = {}, +): string { + // `plan=generic` opts the consent screen into xAI's generic OAuth plan tier; + // without it, accounts.x.ai rejects loopback OAuth from non-allowlisted + // clients. `referrer=opencode` lets xAI attribute opencode-originated + // logins in their OAuth server logs (best-effort attribution while we + // continue to reuse the Grok-CLI client_id). + const params = new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: SCOPE, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + state, + nonce, + plan: "generic", + referrer: "opencode", + }) + return `${options.authorizeUrl ?? AUTHORIZE_URL}?${params.toString()}` +} + +async function exchangeCodeForTokens( + code: string, + pkce: PkceCodes, + options: XaiAuthPluginOptions = {}, +): Promise { + const response = await fetch(options.tokenUrl ?? TOKEN_URL, { + method: "POST", + headers: authHeaders(), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: pkce.verifier, + }).toString(), + }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`xAI token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + return response.json() as Promise +} + +async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise { + const response = await fetch(options.tokenUrl ?? TOKEN_URL, { + method: "POST", + headers: authHeaders(), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`xAI token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + return response.json() as Promise +} + +export interface DeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete?: string + expires_in?: number + interval?: number +} + +interface DeviceTokenErrorBody { + error?: string + error_description?: string +} + +export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Promise { + const response = await fetch(options.deviceAuthorizationUrl ?? DEVICE_AUTHORIZATION_URL, { + method: "POST", + headers: authHeaders(), + body: new URLSearchParams({ + client_id: CLIENT_ID, + scope: SCOPE, + }).toString(), + }) + if (!response.ok) { + const detail = await response.text().catch(() => "") + throw new Error(`xAI device code request failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + const json = (await response.json()) as DeviceCodeResponse + if (!json.device_code || !json.user_code || !json.verification_uri) { + throw new Error("xAI device code response is missing device_code / user_code / verification_uri") + } + return json +} + +// Default sleep used between device-code polls. Test-injectable so we can +// exercise authorization_pending / slow_down branches without real waits. +async function defaultSleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Normalize a server-supplied seconds value to milliseconds, falling back to +// the supplied default when the input is missing, non-positive, or not a +// finite number. Defends the polling loop against garbage like `NaN`, `"NaN"`, +// `null`, or `-5` from a misbehaving device-code endpoint — without this, +// a NaN interval would slip through `?? default` (NaN is typeof number), +// reach `setTimeout(_, NaN)` which is treated as 0, and busy-loop until the +// hard deadline. Matches the defensive normalization Codex uses for the same +// field (`parseInt(deviceData.interval) || 5`). +function positiveSecondsToMs(value: unknown, defaultMs: number): number { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : defaultMs +} + +export async function pollDeviceCodeToken( + device: DeviceCodeResponse, + options: XaiAuthPluginOptions & { sleep?: (ms: number) => Promise; now?: () => number } = {}, +): Promise { + const sleep = options.sleep ?? defaultSleep + const now = options.now ?? (() => Date.now()) + const expiresInMs = positiveSecondsToMs(device.expires_in, DEVICE_CODE_DEFAULT_EXPIRES_MS) + const deadline = now() + expiresInMs + let intervalMs = Math.max( + positiveSecondsToMs(device.interval, DEVICE_CODE_DEFAULT_INTERVAL_MS), + DEVICE_CODE_MIN_INTERVAL_MS, + ) + + while (now() < deadline) { + const response = await fetch(options.tokenUrl ?? TOKEN_URL, { + method: "POST", + headers: authHeaders(), + body: new URLSearchParams({ + grant_type: DEVICE_CODE_GRANT_TYPE, + client_id: CLIENT_ID, + device_code: device.device_code, + }).toString(), + }) + if (response.ok) return (await response.json()) as TokenResponse + + const body = (await response.json().catch(() => ({}))) as DeviceTokenErrorBody + const remaining = Math.max(0, deadline - now()) + // RFC 8628 §3.5: authorization_pending = keep polling at the same + // interval; slow_down = bump the interval by ≥5s and keep polling. + // Anything else is terminal. + if (body.error === "authorization_pending") { + await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining)) + continue + } + if (body.error === "slow_down") { + intervalMs += DEVICE_CODE_SLOW_DOWN_INCREMENT_MS + await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining)) + continue + } + if (body.error === "access_denied" || body.error === "authorization_denied") { + throw new Error("xAI device authorization was denied") + } + if (body.error === "expired_token") { + throw new Error("xAI device code expired - please re-run login") + } + const detail = body.error_description ?? body.error ?? "" + throw new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`) + } + throw new Error("xAI device authorization timed out") +} + +const HTML_SUCCESS = ` + + + OpenCode - xAI Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to OpenCode.

+
+ + +` + +const HTML_ERROR = (error: string) => ` + + + OpenCode - xAI Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${escapeHtml(error)}
+
+ +` + +// CORS allowlist for the loopback callback. The redirect_uri itself is +// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept +// xAI's own auth origins for additional defense-in-depth on the OPTIONS +// preflight. +const CORS_ALLOWED_ORIGINS = new Set(["https://accounts.x.ai", "https://auth.x.ai"]) + +interface PendingOAuth { + pkce: PkceCodes + state: string + resolve: (tokens: TokenResponse) => void + reject: (error: Error) => void +} + +let oauthServer: ReturnType | undefined +let pendingOAuth: PendingOAuth | undefined + +async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { + if (oauthServer) return { port: OAUTH_PORT, redirectUri: REDIRECT_URI } + + const server = createServer((req, res) => { + const reqUrl = req.url || "/" + const url = new URL(reqUrl, `http://${OAUTH_HOST}:${OAUTH_PORT}`) + + const origin = req.headers["origin"] + const allowOrigin = typeof origin === "string" && CORS_ALLOWED_ORIGINS.has(origin) ? origin : "" + if (allowOrigin) { + res.setHeader("Access-Control-Allow-Origin", allowOrigin) + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS") + res.setHeader("Access-Control-Allow-Headers", "Content-Type") + res.setHeader("Access-Control-Allow-Private-Network", "true") + res.setHeader("Vary", "Origin") + } + + if (req.method === "OPTIONS") { + res.writeHead(204) + res.end() + return + } + + if (url.pathname === OAUTH_REDIRECT_PATH) { + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + if (error) { + const errorMsg = errorDescription || error + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + if (!code) { + const errorMsg = "Missing authorization code" + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + if (!pendingOAuth || state !== pendingOAuth.state) { + const errorMsg = "Invalid state - potential CSRF attack" + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + const current = pendingOAuth + pendingOAuth = undefined + + exchangeCodeForTokens(code, current.pkce) + .then((tokens) => current.resolve(tokens)) + .catch((err) => current.reject(err)) + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_SUCCESS) + return + } + + if (url.pathname === "/cancel") { + pendingOAuth?.reject(new Error("Login cancelled")) + pendingOAuth = undefined + res.writeHead(200) + res.end("Login cancelled") + return + } + + res.writeHead(404) + res.end("Not found") + }) + + // listen() failures (e.g. EADDRINUSE because Grok-CLI is bound to the same + // pinned port) must clear `oauthServer` and remove our error listener, + // otherwise the next startOAuthServer() short-circuits on the truthy check + // and returns a redirect_uri pointing at nothing. + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + oauthServer = undefined + reject(err) + } + server.once("error", onError) + server.listen(OAUTH_PORT, OAUTH_HOST, () => { + server.removeListener("error", onError) + // After listen() succeeds, install a permanent log-only listener so + // that subsequent server errors (e.g. accept() failures, socket-level + // errors) don't trip Node's default "unhandled error event = throw" + // behavior and crash the entire opencode process. Matches the silent- + // swallow behavior the Codex plugin gets from its permanent + // `oauthServer!.on("error", reject)`. + server.on("error", (err) => log.warn("xai oauth server error", { error: err })) + log.info("xai oauth server started", { host: OAUTH_HOST, port: OAUTH_PORT }) + resolve() + }) + oauthServer = server + }) + + return { port: OAUTH_PORT, redirectUri: REDIRECT_URI } +} + +function stopOAuthServer() { + if (oauthServer) { + oauthServer.close(() => log.info("xai oauth server stopped")) + oauthServer = undefined + } +} + +function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { + // A previous in-flight authorize() that the user abandoned (or that is + // being superseded by a fresh attempt) still owns `pendingOAuth`. Reject + // it eagerly so its caller stops waiting on a state value that can never + // match the next callback. + if (pendingOAuth) { + pendingOAuth.reject(new Error("Superseded by a newer xAI authorize request")) + pendingOAuth = undefined + } + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + if (pendingOAuth) { + pendingOAuth = undefined + reject(new Error("OAuth callback timeout - authorization took too long")) + } + }, + 5 * 60 * 1000, + ) + + pendingOAuth = { + pkce, + state, + resolve: (tokens) => { + clearTimeout(timeout) + resolve(tokens) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + }, + } + }) +} + +interface RefreshResult { + access: string + refresh: string + expires: number +} + +export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOptions = {}): Promise { + return { + auth: { + provider: "xai", + async loader(getAuth) { + const auth = await getAuth() + if (auth.type !== "oauth") return {} + + // Single-flight refresh: collapse concurrent fetches from this loaded + // provider onto one HTTP call so we don't replay a rotating refresh_token. + let refreshPromise: Promise | undefined + + return { + // Dummy bearer keeps the AI SDK from bailing on "missing apiKey"; the + // real OAuth token is injected by the fetch override below. + // We intentionally do NOT set baseURL — @ai-sdk/xai already defaults + // to https://api.x.ai/v1 and overriding here would silently route + // around a user-configured gateway. + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + let currentAuth = await getAuth() + // Auth can flip from oauth to api mid-session (user re-runs + // /connect with a pasted key). When that happens, pass the + // request through untouched so the AI SDK's own apiKey-based + // Authorization header reaches xAI unmodified. + if (currentAuth.type !== "oauth") return fetch(requestInput, init) + + // Refresh either when the stored expires timestamp is within the + // skew window, or — for JWT access tokens — when the JWT exp + // claim itself is. The stored expires field is best-effort + // (xAI doesn't always return expires_in) so the JWT check is the + // load-bearing one for tokens that lack a fresh stored deadline. + const expiresSoon = + !currentAuth.expires || + currentAuth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS || + accessTokenIsExpiring(currentAuth.access) + if (expiresSoon) { + if (!refreshPromise) { + const refreshToken = currentAuth.refresh + log.info("refreshing xai access token") + refreshPromise = refreshAccessToken(refreshToken, options) + .then(async (tokens) => { + const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const refreshedRefresh = tokens.refresh_token || refreshToken + // Persist the rotated pair as best-effort. xAI has already consumed the + // old refresh_token by the time we get here; an auth.set failure leaves + // the on-disk state stale but the in-memory result is still valid for + // this turn. The next live refresh against the stale disk state will + // 4xx and force re-login — a known cross-process limitation. + await input.client.auth + .set({ + path: { id: "xai" }, + body: { + type: "oauth", + access: tokens.access_token, + refresh: refreshedRefresh, + expires: refreshedExpires, + }, + }) + .catch((err) => log.warn("failed to persist refreshed xai tokens", { error: err })) + return { access: tokens.access_token, refresh: refreshedRefresh, expires: refreshedExpires } + }) + .finally(() => { + refreshPromise = undefined + }) + } + const refreshed = await refreshPromise + currentAuth = { ...currentAuth, ...refreshed } + } + + // Copy the caller's headers into a fresh Headers (case-insensitive) + // so we never mutate the RequestInit the AI SDK may reuse on retry. + // Headers.set overwrites case-insensitively, which kills the dummy + // bearer the AI SDK injected from apiKey in a single line. + const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined) + if (init?.headers) { + const entries = + init.headers instanceof Headers + ? init.headers.entries() + : Array.isArray(init.headers) + ? init.headers + : Object.entries(init.headers as Record) + for (const [key, value] of entries) { + if (value !== undefined) headers.set(key, String(value)) + } + } + headers.set("authorization", `Bearer ${currentAuth.access}`) + headers.set("User-Agent", `opencode/${InstallationVersion}`) + + return fetch(requestInput, { ...init, headers }) + }, + } + }, + methods: [ + { + label: "xAI Grok OAuth (SuperGrok Subscription)", + type: "oauth", + authorize: async () => { + await startOAuthServer() + const pkce = await generatePKCE() + const state = generateState() + const nonce = generateState() + const authUrl = buildAuthorizeUrl(pkce, state, nonce, options) + + const callbackPromise = waitForOAuthCallback(pkce, state) + + return { + url: authUrl, + instructions: "Complete authorization in your browser. This window will close automatically.", + method: "auto" as const, + callback: async () => { + try { + const tokens = await callbackPromise + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + } catch (err) { + log.error("xai oauth callback failed", { error: err }) + return { type: "failed" as const } + } finally { + stopOAuthServer() + } + }, + } + }, + }, + { + // RFC 8628 device-code flow. The CLI prints a verification URL + // and a short user_code that the user enters in a browser on + // any device. No loopback callback server runs on the CLI host, + // so this works on VPS / SSH / Docker / CI / WSL / any + // environment where 127.0.0.1:56121 isn't reachable from the + // user's browser. Defends the only attack surface (the polling + // loop) with the standard authorization_pending / slow_down + // backoff and a hard deadline from xAI's `expires_in`. + label: "xAI Grok OAuth (Headless / Remote / VPS)", + type: "oauth", + authorize: async () => { + const device = await requestDeviceCode(options) + const browserUrl = device.verification_uri_complete ?? device.verification_uri + return { + url: browserUrl, + instructions: `Open ${device.verification_uri} on any device and enter code: ${device.user_code}`, + method: "auto" as const, + callback: async () => { + try { + const tokens = await pollDeviceCodeToken(device, options) + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + } + } catch (err) { + log.error("xai device code callback failed", { error: err }) + return { type: "failed" as const } + } + }, + } + }, + }, + { + label: "Manually enter API Key", + type: "api", + }, + ], + }, + } +} diff --git a/packages/opencode/test/plugin/xai.test.ts b/packages/opencode/test/plugin/xai.test.ts new file mode 100644 index 0000000000..741f29e86b --- /dev/null +++ b/packages/opencode/test/plugin/xai.test.ts @@ -0,0 +1,549 @@ +import { describe, expect, test } from "bun:test" +import { accessTokenIsExpiring, buildAuthorizeUrl, escapeHtml, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin } from "../../src/plugin/xai" +import { OAUTH_DUMMY_KEY } from "../../src/auth" + +function makeJwt(payload: object): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url") + const body = Buffer.from(JSON.stringify(payload)).toString("base64url") + return `${header}.${body}.sig` +} + +function makeInput(opts?: { failSet?: boolean }) { + const setCalls: Array> = [] + return { + input: { + client: { + auth: { + set: async (req: Record) => { + setCalls.push(req) + if (opts?.failSet) throw new Error("auth.set boom") + }, + }, + }, + } as any, + setCalls, + } +} + +function makeServer(handler: (request: Request, url: URL) => Response | Promise) { + return Bun.serve({ + port: 0, + fetch: (request) => handler(request, new URL(request.url)), + }) +} + +function serverOptions(server: ReturnType) { + return { + authorizeUrl: new URL("/oauth2/authorize", server.url).toString(), + tokenUrl: new URL("/oauth2/token", server.url).toString(), + deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString(), + } +} + +describe("plugin.xai", () => { + describe("accessTokenIsExpiring", () => { + test("returns true for an already-expired JWT", () => { + expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 60 }), 0)).toBe(true) + }) + + test("returns false for a fresh JWT outside the skew window", () => { + expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }), 0)).toBe(false) + }) + + test("honors the skew window", () => { + const nearExpiry = makeJwt({ exp: Math.floor(Date.now() / 1000) + 30 }) + expect(accessTokenIsExpiring(nearExpiry, 60_000)).toBe(true) + expect(accessTokenIsExpiring(nearExpiry, 0)).toBe(false) + }) + + test("clamps negative skew to zero rather than refusing to refresh", () => { + expect(accessTokenIsExpiring(makeJwt({ exp: Math.floor(Date.now() / 1000) - 1 }), -60_000)).toBe(true) + }) + + test("returns false for opaque and malformed tokens", () => { + expect(accessTokenIsExpiring("opaque-token-no-dots", 0)).toBe(false) + expect(accessTokenIsExpiring("", 0)).toBe(false) + expect(accessTokenIsExpiring(undefined, 0)).toBe(false) + expect(accessTokenIsExpiring(makeJwt({ sub: "user-1" }), 0)).toBe(false) + expect(accessTokenIsExpiring(makeJwt({ exp: "1234" }), 0)).toBe(false) + expect(accessTokenIsExpiring("header.!!!not-valid-base64-or-json!!!.sig", 0)).toBe(false) + }) + }) + + describe("buildAuthorizeUrl", () => { + const pkce = { verifier: "ver", challenge: "chal" } + + test("includes required OAuth + PKCE + OIDC params", () => { + const url = new URL(buildAuthorizeUrl(pkce, "state-abc", "nonce-xyz")) + const params = url.searchParams + + expect(url.origin + url.pathname).toBe("https://auth.x.ai/oauth2/authorize") + expect(params.get("response_type")).toBe("code") + expect(params.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828") + expect(params.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback") + expect(params.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access") + expect(params.get("code_challenge")).toBe("chal") + expect(params.get("code_challenge_method")).toBe("S256") + expect(params.get("state")).toBe("state-abc") + expect(params.get("nonce")).toBe("nonce-xyz") + expect(params.get("plan")).toBe("generic") + expect(params.get("referrer")).toBe("opencode") + }) + + test("supports endpoint override for local integration tests", () => { + const url = new URL(buildAuthorizeUrl(pkce, "s", "n", { authorizeUrl: "http://127.0.0.1/oauth2/authorize" })) + expect(url.origin + url.pathname).toBe("http://127.0.0.1/oauth2/authorize") + }) + }) + + describe("escapeHtml", () => { + test("escapes HTML metacharacters", () => { + expect(escapeHtml(`
`)).toBe( + "</div><script>alert(1)</script><div class="x">", + ) + expect(escapeHtml("a & b")).toBe("a & b") + expect(escapeHtml("it's fine")).toBe("it's fine") + expect(escapeHtml("invalid_grant")).toBe("invalid_grant") + expect(escapeHtml("")).toBe("") + expect(escapeHtml("&<")).toBe("&<") + }) + }) + + describe("loader", () => { + test("returns no options unless stored auth is OAuth and exposes methods in order", async () => { + const hooks = await XaiAuthPlugin({} as any) + expect(await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }), {} as any)).toEqual({}) + expect(await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any)).toEqual({}) + expect(hooks.auth!.methods.map((m) => [m.type, m.label])).toEqual([ + ["oauth", "xAI Grok OAuth (SuperGrok Subscription)"], + ["oauth", "xAI Grok OAuth (Headless / Remote / VPS)"], + ["api", "Manually enter API Key"], + ]) + }) + + test("replaces the dummy bearer, sets User-Agent, and preserves caller headers", async () => { + const { input } = makeInput() + const captured: Headers[] = [] + using server = makeServer((request) => { + captured.push(request.headers) + return new Response("{}", { status: 200 }) + }) + const hooks = await XaiAuthPlugin(input) + const opts = await hooks.auth!.loader!( + async () => ({ type: "oauth", access: "live-token", refresh: "rt", expires: Date.now() + 3600_000 }), + {} as any, + ) + expect(opts.apiKey).toBe(OAUTH_DUMMY_KEY) + expect(opts.baseURL).toBeUndefined() + + await opts.fetch!(new URL("/chat/completions", server.url), { + headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" }, + }) + + expect(captured[0].get("authorization")).toBe("Bearer live-token") + expect(captured[0].get("x-keep")).toBe("yes") + expect(captured[0].get("user-agent")).toMatch(/^opencode\//) + }) + + test("does not mutate caller headers and supports HeadersInit shapes", async () => { + const { input } = makeInput() + const captured: Headers[] = [] + using server = makeServer((request) => { + captured.push(request.headers) + return new Response("{}", { status: 200 }) + }) + const opts = await (await XaiAuthPlugin(input)).auth!.loader!( + async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }), + {} as any, + ) + + const objHeaders: Record = { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" } + await opts.fetch!(new URL("/chat/completions", server.url), { headers: objHeaders }) + expect(objHeaders).toEqual({ Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" }) + + const arrayHeaders: [string, string][] = [["x-trace", "tuple-array"]] + const arrayCopy = arrayHeaders.map(([key, value]) => [key, value] as [string, string]) + await opts.fetch!(new URL("/chat/completions", server.url), { headers: arrayHeaders }) + expect(arrayHeaders).toEqual(arrayCopy) + + const headersInstance = new Headers({ "x-trace": "headers-instance" }) + await opts.fetch!(new URL("/chat/completions", server.url), { headers: headersInstance }) + expect(headersInstance.get("x-trace")).toBe("headers-instance") + + expect(captured.map((headers) => headers.get("x-trace"))).toEqual(["plain-object", "tuple-array", "headers-instance"]) + for (const headers of captured) { + expect(headers.get("authorization")).toBe("Bearer tok") + expect(headers.get("user-agent")).toMatch(/^opencode\//) + } + }) + + test("preserves headers from Request input and lets init headers override them", async () => { + const { input } = makeInput() + const captured: Headers[] = [] + using server = makeServer((request) => { + captured.push(request.headers) + return new Response("{}", { status: 200 }) + }) + const opts = await (await XaiAuthPlugin(input)).auth!.loader!( + async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }), + {} as any, + ) + + await opts.fetch!( + new Request(new URL("/chat/completions", server.url), { + headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "content-type": "application/json", "x-trace": "request" }, + }), + { headers: { "x-trace": "init", "x-extra": "yes" } }, + ) + + expect(captured[0].get("authorization")).toBe("Bearer tok") + expect(captured[0].get("content-type")).toBe("application/json") + expect(captured[0].get("x-trace")).toBe("init") + expect(captured[0].get("x-extra")).toBe("yes") + }) + + test("falls through to plain fetch when stored auth flips from oauth to api", async () => { + const { input } = makeInput() + const captured: Headers[] = [] + using server = makeServer((request) => { + captured.push(request.headers) + return new Response("{}", { status: 200 }) + }) + let firstCall = true + const opts = await (await XaiAuthPlugin(input)).auth!.loader!(async () => { + if (firstCall) { + firstCall = false + return { type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 } + } + return { type: "api", key: "sk-new" } + }, {} as any) + + await opts.fetch!(new URL("/chat/completions", server.url), { + headers: { Authorization: "Bearer sk-from-aisdk", "x-keep": "v" }, + }) + expect(captured[0].get("authorization")).toBe("Bearer sk-from-aisdk") + expect(captured[0].get("x-keep")).toBe("v") + }) + + test("deduplicates concurrent refreshes within a loader instance", async () => { + const { input, setCalls } = makeInput() + let tokenRequests = 0 + const apiRequests: Headers[] = [] + using server = makeServer(async (request, url) => { + if (url.pathname === "/oauth2/token") { + tokenRequests++ + expect(await request.text()).toContain("refresh_token=rt-old") + await new Promise((resolve) => setTimeout(resolve, 30)) + return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 }) + } + apiRequests.push(request.headers) + return new Response("{}", { status: 200 }) + }) + const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + async () => ({ type: "oauth" as const, access: "old", refresh: "rt-old", expires: 0 }), + {} as any, + ) + + await Promise.all([ + opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }), + opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }), + ]) + + expect(tokenRequests).toBe(1) + expect(apiRequests.map((headers) => headers.get("authorization"))).toEqual(["Bearer new-access", "Bearer new-access"]) + expect(setCalls).toHaveLength(1) + expect((setCalls[0].body as any).refresh).toBe("rt-new") + }) + + test("does not share refresh single-flight across loader instances", async () => { + const { input } = makeInput() + const tokenRequests: string[] = [] + const apiRequests: string[] = [] + using server = makeServer(async (request, url) => { + if (url.pathname === "/oauth2/token") { + const refreshToken = new URLSearchParams(await request.text()).get("refresh_token")! + tokenRequests.push(refreshToken) + await new Promise((resolve) => setTimeout(resolve, 20)) + return Response.json({ access_token: `access-${refreshToken}`, refresh_token: `next-${refreshToken}`, expires_in: 3600 }) + } + apiRequests.push(request.headers.get("authorization")!) + return new Response("{}", { status: 200 }) + }) + const hooks = await XaiAuthPlugin(input, serverOptions(server)) + const first = await hooks.auth!.loader!(async () => ({ type: "oauth", access: "old-a", refresh: "rt-a", expires: 0 }), {} as any) + const second = await hooks.auth!.loader!(async () => ({ type: "oauth", access: "old-b", refresh: "rt-b", expires: 0 }), {} as any) + + await Promise.all([ + first.fetch!(new URL("/chat/completions", server.url), { headers: {} }), + second.fetch!(new URL("/chat/completions", server.url), { headers: {} }), + ]) + + expect(tokenRequests.sort()).toEqual(["rt-a", "rt-b"]) + expect(apiRequests.sort()).toEqual(["Bearer access-rt-a", "Bearer access-rt-b"]) + }) + + test("starts a new refresh after success and clears the refresh promise after failure", async () => { + const { input } = makeInput() + let tokenRequests = 0 + using server = makeServer((_, url) => { + if (url.pathname === "/oauth2/token") { + tokenRequests++ + if (tokenRequests === 2) return new Response("temporarily unavailable", { status: 503 }) + return Response.json({ access_token: `new-${tokenRequests}`, refresh_token: `rt-${tokenRequests}`, expires_in: 3600 }) + } + return new Response("{}", { status: 200 }) + }) + const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), + {} as any, + ) + + await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }) + await expect(opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })).rejects.toThrow(/xAI token refresh failed \(503\)/) + await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }) + expect(tokenRequests).toBe(3) + }) + + test("handles refresh response variants and persistence failure", async () => { + const { input, setCalls } = makeInput({ failSet: true }) + const captured: Headers[] = [] + using server = makeServer((request, url) => { + if (url.pathname === "/oauth2/token") return Response.json({ access_token: "new-access", expires_in: 3600 }) + captured.push(request.headers) + return new Response("{}", { status: 200 }) + }) + const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), + {} as any, + ) + + const resp = await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }) + expect(resp.status).toBe(200) + expect(captured[0].get("authorization")).toBe("Bearer new-access") + expect((setCalls[0].body as any).refresh).toBe("rt-old") + }) + + test("refreshes based on stored expiry or JWT expiry and skips refresh when both are fresh", async () => { + const { input, setCalls } = makeInput() + let tokenRequests = 0 + using server = makeServer((_, url) => { + if (url.pathname === "/oauth2/token") { + tokenRequests++ + return Response.json({ access_token: "new-access", refresh_token: "rt-new", expires_in: 3600 }) + } + return new Response("{}", { status: 200 }) + }) + const fresh = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + async () => ({ + type: "oauth", + access: makeJwt({ exp: Math.floor(Date.now() / 1000) + 24 * 3600 }), + refresh: "rt", + expires: Date.now() + 24 * 3600 * 1000, + }), + {} as any, + ) + await fresh.fetch!(new URL("/chat/completions", server.url), { headers: {} }) + expect(tokenRequests).toBe(0) + + const jwtExpiring = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + async () => ({ + type: "oauth", + access: makeJwt({ exp: Math.floor((Date.now() + 30_000) / 1000) }), + refresh: "rt-old", + expires: Date.now() + 24 * 3600 * 1000, + }), + {} as any, + ) + const missingExpires = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + async () => ({ type: "oauth", access: "opaque-token", refresh: "rt", expires: 0 }), + {} as any, + ) + await jwtExpiring.fetch!(new URL("/chat/completions", server.url), { headers: {} }) + await missingExpires.fetch!(new URL("/chat/completions", server.url), { headers: {} }) + expect(tokenRequests).toBe(2) + expect(setCalls).toHaveLength(2) + }) + + test("network failure during refresh surfaces the underlying fetch error", async () => { + const { input } = makeInput() + const opts = await (await XaiAuthPlugin(input, { tokenUrl: "http://127.0.0.1:9/oauth2/token" })).auth!.loader!( + async () => ({ type: "oauth", access: "old", refresh: "rt", expires: 0 }), + {} as any, + ) + + await expect(opts.fetch!("https://api.x.ai/v1/chat/completions", { headers: {} })).rejects.toThrow() + }) + }) + + describe("device code flow", () => { + test("authorize advertises verification URL + user code and returns success on callback", async () => { + using server = makeServer((_, url) => { + if (url.pathname === "/oauth2/device/code") { + return Response.json({ + device_code: "DEVICE-1", + user_code: "ABCD-1234", + verification_uri: "https://x.ai/device", + verification_uri_complete: "https://x.ai/device?user_code=ABCD-1234", + expires_in: 600, + interval: 5, + }) + } + if (url.pathname === "/oauth2/token") { + return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) + } + return new Response("unexpected request", { status: 500 }) + }) + const hooks = await XaiAuthPlugin({} as any, serverOptions(server)) + const headless = hooks.auth!.methods.find((m): m is Extract => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)")! + const result = await headless.authorize!() + + expect(result.method).toBe("auto") + expect(result.url).toBe("https://x.ai/device?user_code=ABCD-1234") + expect(result.instructions).toContain("https://x.ai/device") + expect(result.instructions).toContain("ABCD-1234") + expect(await (result as any).callback()).toMatchObject({ type: "success", refresh: "RT", access: "AT" }) + }) + + test("authorize falls back to verification_uri when verification_uri_complete is absent", async () => { + using server = makeServer((_, url) => { + if (url.pathname === "/oauth2/device/code") { + return Response.json({ device_code: "DEVICE-2", user_code: "WXYZ-9876", verification_uri: "https://x.ai/device" }) + } + return new Response("unexpected request", { status: 500 }) + }) + const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( + (m): m is Extract => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + )! + expect((await headless.authorize!()).url).toBe("https://x.ai/device") + }) + + test("requestDeviceCode posts form body, validates fields, and surfaces endpoint errors", async () => { + let capturedBody = "" + using server = makeServer(async (request, url) => { + if (url.pathname === "/missing") return Response.json({ device_code: "x" }) + if (url.pathname === "/error") return new Response("rate limited", { status: 429 }) + expect(request.method).toBe("POST") + expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded") + expect(request.headers.get("accept")).toBe("application/json") + expect(request.headers.get("user-agent")).toMatch(/^opencode\//) + capturedBody = await request.text() + return Response.json({ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device" }) + }) + + await requestDeviceCode({ deviceAuthorizationUrl: new URL("/oauth2/device/code", server.url).toString() }) + const parsed = new URLSearchParams(capturedBody) + expect(parsed.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828") + expect(parsed.get("scope")).toContain("offline_access") + expect(parsed.get("scope")).toContain("grok-cli:access") + expect(parsed.get("scope")).toContain("api:access") + await expect(requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() })).rejects.toThrow(/429.*rate limited/) + await expect(requestDeviceCode({ deviceAuthorizationUrl: new URL("/missing", server.url).toString() })).rejects.toThrow(/missing device_code/) + }) + + test("pollDeviceCodeToken resolves on success and posts the device-code grant", async () => { + let tokenCalls = 0 + using server = makeServer(async (request) => { + tokenCalls++ + expect(request.headers.get("content-type")).toBe("application/x-www-form-urlencoded") + const body = new URLSearchParams(await request.text()) + expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code") + expect(body.get("device_code")).toBe("DC-1") + return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) + }) + + const tokens = await pollDeviceCodeToken( + { device_code: "DC-1", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 600 }, + { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() }, + ) + expect(tokens.access_token).toBe("AT") + expect(tokens.refresh_token).toBe("RT") + expect(tokenCalls).toBe(1) + }) + + test("pollDeviceCodeToken honors authorization_pending and slow_down", async () => { + let n = 0 + using server = makeServer(() => { + n++ + if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 }) + if (n === 2) return Response.json({ error: "slow_down" }, { status: 400 }) + return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) + }) + const sleeps: number[] = [] + const tokens = await pollDeviceCodeToken( + { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 5, expires_in: 600 }, + { sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() }, + ) + expect(tokens.access_token).toBe("AT") + expect(n).toBe(3) + expect(sleeps).toEqual([8_000, 13_000]) + }) + + test("pollDeviceCodeToken handles terminal errors and timeout", async () => { + for (const [body, error] of [ + [{ error: "access_denied" }, /authorization was denied/], + [{ error: "expired_token" }, /device code expired/], + [{ error: "server_error", error_description: "oops" }, /500.*oops/], + ] as const) { + using server = makeServer(() => Response.json(body, { status: 500 })) + await expect( + pollDeviceCodeToken( + { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 600 }, + { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() }, + ), + ).rejects.toThrow(error) + } + + using pending = makeServer(() => Response.json({ error: "authorization_pending" }, { status: 400 })) + let tick = 0 + await expect( + pollDeviceCodeToken( + { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 1 }, + { sleep: async () => {}, now: () => 1_000_000 + tick++ * 600, tokenUrl: new URL("/oauth2/token", pending.url).toString() }, + ), + ).rejects.toThrow(/timed out/) + }) + + test("pollDeviceCodeToken normalizes bad interval and expires_in values", async () => { + const badIntervals: Array = [Number.NaN, "NaN", "garbage", -5, null, 0] + for (const bad of badIntervals) { + let n = 0 + using server = makeServer(() => { + n++ + if (n === 1) return Response.json({ error: "authorization_pending" }, { status: 400 }) + return Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) + }) + const sleeps: number[] = [] + await pollDeviceCodeToken( + { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: bad as number, expires_in: 600 }, + { sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() }, + ) + expect(sleeps[0]).toBe(8_000) + } + + for (const bad of [Number.NaN, "NaN", "garbage", -5, null, 0]) { + using server = makeServer(() => Response.json({ access_token: "AT", refresh_token: "RT", expires_in: 3600 })) + expect( + ( + await pollDeviceCodeToken( + { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: bad as number }, + { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() }, + ) + ).access_token, + ).toBe("AT") + } + }) + + test("device-code authorize callback returns failed when polling errors", async () => { + using server = makeServer((_, url) => { + if (url.pathname === "/oauth2/device/code") { + return Response.json({ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 0, expires_in: 600 }) + } + return Response.json({ error: "access_denied" }, { status: 400 }) + }) + const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( + (m): m is Extract => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + )! + expect(await (await headless.authorize!() as any).callback()).toEqual({ type: "failed" }) + }) + }) +}) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 33efa03591..fd25227712 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -2048,6 +2048,46 @@ Some useful routing options: ### xAI +Three ways to authenticate: a SuperGrok subscription via browser OAuth, the same SuperGrok subscription via a headless device-code flow (for VPS / SSH / Docker), or a pay-as-you-go API key from the xAI console. + +#### Option A — SuperGrok OAuth (browser login) + +1. Run the `/connect` command and search for **xAI**. + + ```txt + /connect + ``` + +2. Select **xAI Grok OAuth (SuperGrok Subscription)**. OpenCode opens xAI's consent screen in your browser and waits for the callback on `http://127.0.0.1:56121/callback`. + +3. Run the `/models` command to select a Grok model. + + ```txt + /models + ``` + +OpenCode refreshes the OAuth access token automatically. Any Grok or X Premium plan that includes Grok API access works; you do not need a separate `XAI_API_KEY`. + +#### Option B — SuperGrok device-code (headless / remote server / VPS) + +Use this when OpenCode is running somewhere a browser can't reach the loopback redirect: a VPS, a remote dev box over SSH, inside Docker, in CI, etc. No callback port is opened on the host running OpenCode — instead xAI hands the CLI a short code that you type into a browser on any other device (laptop, phone, …). + +1. Run the `/connect` command on the remote host and search for **xAI**. + + ```txt + /connect + ``` + +2. Select **xAI Grok OAuth (Headless / Remote / VPS)**. OpenCode prints a verification URL and a short user code. + + ```txt + Open https://x.ai/device on any device and enter code: ABCD-1234 + ``` + +3. Open the URL on a device that has a browser (your laptop or phone), enter the code, and approve the consent screen. OpenCode polls xAI's token endpoint and stores the resulting OAuth tokens once you approve. Token refresh works the same as Option A. + +#### Option C — API key + 1. Head over to the [xAI console](https://console.x.ai/), create an account, and generate an API key. 2. Run the `/connect` command and search for **xAI**. @@ -2056,7 +2096,7 @@ Some useful routing options: /connect ``` -3. Enter your xAI API key. +3. Select **Manually enter API Key** and paste your xAI API key. ```txt ┌ API key