From 0df0ff2ddb7d4adc5ed2a9ce44a10648cf85a438 Mon Sep 17 00:00:00 2001 From: backryun Date: Tue, 21 Jul 2026 07:28:21 +0900 Subject: [PATCH] feat(grok-cli): align with official Grok Build client (#7358) Rebuilt clean on release/v3.8.49 (branch forked from old main, ~drift). Resolved 2 real conflicts against the current tip: providerModelsConfig.ts keeps BOTH the tip's DashScope text-model helpers (#7882) and this PR's ProviderModelsHeaderContext type; OAuthModal.tsx takes this PR's DEVICE_CODE_PROVIDERS set (superset of the tip's hardcoded chain + grok-cli), dropping the now-dead qwen entry (#7866 removed qwen OAuth). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/config/grokBuild.ts | 142 +++++ .../providers/registry/grok-cli/index.ts | 45 +- open-sse/executors/base.ts | 1 + open-sse/executors/grok-cli.ts | 519 +++++++----------- .../api/oauth/[provider]/[action]/route.ts | 29 +- .../models/discovery/providerModelsConfig.ts | 142 ++++- src/lib/oauth/constants/oauth.ts | 13 +- src/lib/oauth/providers/grok-cli.ts | 351 +++++++++--- src/shared/components/OAuthModal.tsx | 414 +++++++------- src/shared/components/OAuthModalPanels.tsx | 201 +++++++ tests/unit/grok-cli-device-route.test.ts | 58 ++ tests/unit/grok-cli-oauth.test.ts | 296 +++++++++- .../grok-cli-proactive-refresh-7610.test.ts | 80 ++- tests/unit/grok-cli-proxy-selection.test.ts | 63 +-- .../grok-cli-reasoning-strip-6288.test.ts | 121 ++-- tests/unit/grok-cli-responses-compat.test.ts | 184 +++++++ tests/unit/grok-cli-strip-params.test.ts | 23 +- .../unit/ui/grok-device-oauth-modal.test.tsx | 154 ++++++ 18 files changed, 2044 insertions(+), 792 deletions(-) create mode 100644 open-sse/config/grokBuild.ts create mode 100644 src/shared/components/OAuthModalPanels.tsx create mode 100644 tests/unit/grok-cli-device-route.test.ts create mode 100644 tests/unit/grok-cli-responses-compat.test.ts create mode 100644 tests/unit/ui/grok-device-oauth-modal.test.tsx diff --git a/open-sse/config/grokBuild.ts b/open-sse/config/grokBuild.ts new file mode 100644 index 0000000000..c6e7dca7bf --- /dev/null +++ b/open-sse/config/grokBuild.ts @@ -0,0 +1,142 @@ +import { getRuntimeArch, getRuntimePlatform } from "./providerHeaderProfiles.ts"; + +export const GROK_BUILD_PROXY_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; +export const GROK_BUILD_RESPONSES_URL = `${GROK_BUILD_PROXY_BASE_URL}/responses`; +export const GROK_BUILD_MODELS_URL = `${GROK_BUILD_PROXY_BASE_URL}/models`; + +export const GROK_BUILD_OAUTH_ISSUER = "https://auth.x.ai"; +export const GROK_BUILD_DEVICE_CODE_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/device/code`; +export const GROK_BUILD_TOKEN_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/token`; + +export const GROK_BUILD_DEFAULT_CLIENT_VERSION = "0.2.106"; +export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 256_000; +export const GROK_BUILD_DEFAULT_REASONING_EFFORT = "high"; +export const GROK_BUILD_CLIENT_IDENTIFIER = "grok-shell"; +export const GROK_BUILD_TOKEN_AUTH = "xai-grok-cli"; +export const GROK_BUILD_REASONING_INCLUDE = "reasoning.encrypted_content"; +export const GROK_BUILD_OAUTH_REFERRER = "grok-build"; + +export const GROK_BUILD_OAUTH_SCOPES = Object.freeze([ + "openid", + "profile", + "email", + "offline_access", + "grok-cli:access", + "api:access", + "conversations:read", + "conversations:write", + "workspaces:read", + "workspaces:write", +]); + +export type GrokBuildClientMode = "headless" | "interactive"; +export type GrokBuildClientSurface = "ui" | "cli" | "headless"; + +export type GrokBuildSessionHeaderOptions = { + token?: string | null; + model?: string | null; + stream?: boolean; + clientMode?: GrokBuildClientMode; + userId?: string | null; + email?: string | null; + principalType?: string | null; +}; + +function getWireEmail(email?: string | null, principalType?: string | null): string | null { + const normalizedPrincipalType = principalType?.trim().toLowerCase(); + return normalizedPrincipalType === "team" || normalizedPrincipalType === "organization" + ? null + : email || null; +} + +function mapPlatform(platform: string): string { + if (platform === "darwin") return "macos"; + if (platform === "win32") return "windows"; + return platform; +} + +function mapArch(arch: string): string { + if (arch === "arm64") return "aarch64"; + if (arch === "x64") return "x86_64"; + return arch; +} + +export function getGrokBuildClientVersion(): string { + return GROK_BUILD_DEFAULT_CLIENT_VERSION; +} + +export function getGrokBuildUserAgent(): string { + return `${GROK_BUILD_CLIENT_IDENTIFIER}/${getGrokBuildClientVersion()} (${mapPlatform( + getRuntimePlatform() + )}; ${mapArch(getRuntimeArch())})`; +} + +export function getGrokBuildClientHeaders( + clientMode: GrokBuildClientMode = "headless" +): Record { + return { + "x-grok-client-version": getGrokBuildClientVersion(), + "x-grok-client-identifier": GROK_BUILD_CLIENT_IDENTIFIER, + "x-grok-client-mode": clientMode, + "User-Agent": getGrokBuildUserAgent(), + }; +} + +export function getGrokBuildSessionHeaders({ + token, + model, + stream = false, + clientMode = "headless", + userId, + email, + principalType, +}: GrokBuildSessionHeaderOptions = {}): Record { + const wireEmail = getWireEmail(email, principalType); + return { + "Content-Type": "application/json", + Accept: stream ? "text/event-stream" : "application/json", + ...getGrokBuildClientHeaders(clientMode), + "X-XAI-Token-Auth": GROK_BUILD_TOKEN_AUTH, + "x-authenticateresponse": "authenticate-response", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(model ? { "x-grok-model-override": model } : {}), + ...(userId + ? { + "x-userid": userId, + "x-grok-user-id": userId, + } + : {}), + ...(wireEmail ? { "x-email": wireEmail } : {}), + }; +} + +export function getGrokBuildOAuthHeaders( + surface: GrokBuildClientSurface = "ui" +): Record { + return { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "x-grok-client-version": getGrokBuildClientVersion(), + "x-grok-client-surface": surface, + }; +} + +export function getGrokBuildModelsHeaders({ + token, + userId, + email, + principalType, +}: Pick): Record< + string, + string +> { + const wireEmail = getWireEmail(email, principalType); + return { + Accept: "application/json", + ...getGrokBuildClientHeaders("headless"), + "X-XAI-Token-Auth": GROK_BUILD_TOKEN_AUTH, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(userId ? { "x-userid": userId } : {}), + ...(wireEmail ? { "x-email": wireEmail } : {}), + }; +} diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index dd9c21150c..75effafe78 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -1,50 +1,47 @@ import type { RegistryEntry } from "../../shared.ts"; import { resolvePublicCred } from "../../shared.ts"; +import { + getGrokBuildClientVersion, + GROK_BUILD_MODELS_URL, + GROK_BUILD_TOKEN_URL, +} from "../../../grokBuild.ts"; export const grok_cliProvider: RegistryEntry = { id: "grok-cli", alias: "gc", format: "openai", executor: "grok-cli", + // Keep the generic translate-path contract stable. GrokCliExecutor owns the + // official Grok Build upstream URL and always dispatches to /v1/responses. baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions", + modelsUrl: GROK_BUILD_MODELS_URL, + clientVersion: getGrokBuildClientVersion(), authType: "oauth", authHeader: "bearer", passthroughModels: true, models: [ { - id: "grok-build", - name: "Grok Build", - contextLength: 256000, - // cli-chat-proxy rejects reasoning_effort/reasoning outright (see grok-cli.ts - // executor's transformRequest, which strips them unconditionally for this model). - supportsReasoning: false, - unsupportedParams: [ - "presencePenalty", - "frequencyPenalty", - "logprobs", - "topLogprobs", - "reasoningEffort", - ], + id: "grok-4.5", + name: "Grok 4.5", + contextLength: 500000, + supportsReasoning: true, + toolCalling: true, + targetFormat: "openai-responses", + unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"], }, { id: "grok-composer-2.5-fast", - name: "Grok Composer 2.5 Fast", + name: "Composer 2.5", contextLength: 200000, - // cli-chat-proxy rejects reasoning_effort/reasoning outright (see grok-cli.ts - // executor's transformRequest, which strips them unconditionally for this model). supportsReasoning: false, - unsupportedParams: [ - "presencePenalty", - "frequencyPenalty", - "logprobs", - "topLogprobs", - "reasoningEffort", - ], + toolCalling: true, + targetFormat: "openai-responses", + unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"], }, ], oauth: { clientIdEnv: "GROK_OAUTH_CLIENT_ID", clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), - tokenUrl: "https://auth.x.ai/oauth2/token", + tokenUrl: GROK_BUILD_TOKEN_URL, }, }; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 607fde251e..4dadb81986 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -142,6 +142,7 @@ export type ProviderCredentials = { accessToken?: string; refreshToken?: string; apiKey?: string; + email?: string | null; projectId?: string | null; expiresAt?: string; connectionId?: string; // T07: used for API key rotation index diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index c28813c069..f0e7f6e398 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -1,74 +1,139 @@ /** * GrokCliExecutor — Grok Build Provider * - * Routes requests through Grok's chat proxy endpoint using OAuth authentication. - * Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking - * (only for the no-proxy direct path — see resolveGrokRequestDispatch below). - * Supports automatic token refresh via refresh_token. + * Routes Responses API requests through Grok's chat proxy using OAuth authentication. + * The standard BaseExecutor transport provides streaming, retries, abort propagation, + * proxy-aware fetch dispatch, upstream-header merging, and credential-refresh persistence. */ -import { - BaseExecutor, - type ExecuteInput, - type ExecutorLog, - type ProviderCredentials, -} from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; +import { + getGrokBuildSessionHeaders, + GROK_BUILD_DEFAULT_REASONING_EFFORT, + GROK_BUILD_REASONING_INCLUDE, + GROK_BUILD_RESPONSES_URL, + GROK_BUILD_TOKEN_URL, +} from "../config/grokBuild.ts"; import { resolvePublicCred } from "../utils/publicCreds.ts"; -import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; -import { runWithOnPersist, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts"; -import https from "node:https"; -import { HttpsProxyAgent } from "https-proxy-agent"; +import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts"; -const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token"; -const REQUEST_TIMEOUT_MS = 60_000; -// xAI cli-chat-proxy hard limit on tools per request. -const MAX_TOOLS = 200; +const GROK_BUILD_MAX_TOOLS = 200; +const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high"]); +const GROK_BUILD_REFRESH_MAX_ATTEMPTS = 3; +const GROK_BUILD_REFRESH_MIN_DELAY_MS = 200; +const GROK_BUILD_TERMINAL_REFRESH_ERRORS = new Set(["invalid_grant", "invalid_client"]); +const GROK_BUILD_UNSUPPORTED_PARAMS = [ + "presencePenalty", + "frequencyPenalty", + "logprobs", + "topLogprobs", + "presence_penalty", + "frequency_penalty", + "top_logprobs", + "reasoning_effort", +]; -type ProxyResolution = { source: string; proxyUrl: string | null }; -type GrokRequestDispatch = { agent?: https.Agent; family?: 4 }; +type GrokBuildRefreshResult = Partial | null | undefined; -/** - * Resolve how a Grok Build request to `targetUrl` should egress: through the - * operator's configured proxy (connection/provider/global — whatever the caller - * already pinned via `runWithProxyContext` upstream in chatHelpers.ts) when one - * is set, or direct with the existing forced-IPv4 workaround when none is. - * - * This executor talks to Grok via raw `https.request()` instead of the global - * patched `fetch()` (every other executor's path), so it never consulted the - * proxy context at all — a configured proxy was silently ignored and the - * request always egressed on the host's real IP. Only HTTP/HTTPS (CONNECT) - * proxies are supported here; an explicitly configured proxy of another kind - * (e.g. SOCKS5) fails closed rather than silently falling back to direct, - * matching the "fail closed for OAuth usage account proxies" convention (#3051). - * - * `resolveProxy` is injectable for tests; defaults to the shared - * `resolveProxyForRequest` used by the patched global fetch. - */ -export function resolveGrokRequestDispatch( - targetUrl: string, - resolveProxy: (url: string) => ProxyResolution = resolveProxyForRequest -): GrokRequestDispatch { - const { proxyUrl } = resolveProxy(targetUrl); +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} - if (!proxyUrl) { - return { family: 4 }; - } - - let protocol: string; - try { - protocol = new URL(proxyUrl).protocol; - } catch { - throw new Error("Grok Build: configured proxy URL could not be parsed"); - } - - if (protocol === "http:" || protocol === "https:") { - return { agent: new HttpsProxyAgent(proxyUrl) as unknown as https.Agent }; - } - - throw new Error( - "Grok Build: configured proxy protocol is not supported for this provider (HTTP/HTTPS proxies only)" +function getRefreshRetryDelayMs(retryNumber: number): number { + const baseDelay = Math.min( + 2_000, + GROK_BUILD_REFRESH_MIN_DELAY_MS * 2 ** Math.max(0, retryNumber - 1) ); + return Math.max(1, Math.round(baseDelay * (0.5 + Math.random()))); +} + +function asRequestRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +} + +function ensureReasoningInclude(value: unknown): unknown[] { + const include = Array.isArray(value) ? [...value] : []; + if (!include.includes(GROK_BUILD_REASONING_INCLUDE)) { + include.push(GROK_BUILD_REASONING_INCLUDE); + } + return include; +} + +function normalizeGrokBuildReasoning( + value: unknown, + model: string +): Record | null { + const reasoning = asRequestRecord(value); + const hasExplicitEffort = Object.prototype.hasOwnProperty.call(reasoning, "effort"); + if (!GROK_BUILD_SUPPORTED_REASONING_EFFORTS.has(String(reasoning.effort))) { + delete reasoning.effort; + } + if (model === "grok-composer-2.5-fast") { + delete reasoning.effort; + } else if (model === "grok-4.5" && !hasExplicitEffort) { + reasoning.effort = GROK_BUILD_DEFAULT_REASONING_EFFORT; + } + return Object.keys(reasoning).length > 0 ? reasoning : null; +} + +function stripUnsupportedGrokBuildParams(request: Record): void { + for (const param of GROK_BUILD_UNSUPPORTED_PARAMS) { + delete request[param]; + } +} + +async function refreshGrokBuildCredentialsOnce( + body: URLSearchParams, + credentials: ProviderCredentials, + attempt: number, + log?: ExecutorLog | null +): Promise { + try { + const response = await fetch(GROK_BUILD_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: AbortSignal.timeout(15_000), + }); + const data = (await response.json().catch(() => ({}))) as Record; + + if (!response.ok) { + const errorCode = nonEmptyString(data.error); + const isTerminal = + attempt === GROK_BUILD_REFRESH_MAX_ATTEMPTS || + (errorCode !== null && GROK_BUILD_TERMINAL_REFRESH_ERRORS.has(errorCode)); + log?.warn?.("TOKEN_REFRESH", `Grok Build: refresh failed with status ${response.status}`); + return isTerminal ? null : undefined; + } + + const accessToken = nonEmptyString(data.access_token); + if (!accessToken) { + log?.warn?.("TOKEN_REFRESH", "Grok Build: no access_token in refresh response"); + return attempt === GROK_BUILD_REFRESH_MAX_ATTEMPTS ? null : undefined; + } + + const expiresIn = + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in > 0 + ? data.expires_in + : 21600; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + log?.info?.("TOKEN_REFRESH", `Grok Build: token refreshed, expires ${expiresAt}`); + + return { + accessToken, + refreshToken: nonEmptyString(data.refresh_token) || credentials.refreshToken, + expiresAt, + }; + } catch (error) { + log?.warn?.( + "TOKEN_REFRESH", + `Grok Build: refresh error: ${error instanceof Error ? error.message : String(error)}` + ); + return attempt === GROK_BUILD_REFRESH_MAX_ATTEMPTS ? null : undefined; + } } export class GrokCliExecutor extends BaseExecutor { @@ -76,77 +141,13 @@ export class GrokCliExecutor extends BaseExecutor { super("grok-cli", PROVIDERS["grok-cli"]); } - async execute(input: ExecuteInput) { - const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input; - - // #7610: unlike BaseExecutor.execute() (which most executors inherit or - // delegate to via super.execute()), this executor talks upstream via raw - // https.request() (nativePost) instead of the shared fetch path, so it - // never picked up the base class's proactive refresh gate. Without it, - // xAI's rotating refresh_token idled until real expiry — the only refresh - // that fired was the reactive one on a 401/403 from upstream — matching - // the "unusable within minutes" report. Apply the same gate here. - const activeCredentials = await this.applyProactiveRefresh( - credentials, - log, - onCredentialsRefreshed - ); - - const url = this.buildUrl(model, stream, 0, activeCredentials); - const headers = this.buildHeaders(activeCredentials, stream); - const transformedBody = this.transformRequest(model, body, stream, activeCredentials); - const bodyStr = JSON.stringify(transformedBody); - - const response = await this.nativePost(url, headers, bodyStr, signal); - return { response, url, headers, transformedBody }; - } - - /** - * Proactive-refresh gate mirroring BaseExecutor.execute()'s (base.ts:599-685), - * scoped to grok-cli's single-URL nativePost dispatch (no fallback-URL retry - * loop to thread through). xAI uses rotating refresh tokens (same family as - * Codex/Claude) — `runWithOnPersist` keeps the [refresh + persist] atomic - * under the same per-connection mutex `getAccessToken` uses, and - * `isUnrecoverableRefreshError` keeps a reused/invalid sentinel from being - * spread into the outgoing credentials — see base.ts:622-673 for the full - * regression history this mirrors. - */ - private async applyProactiveRefresh( - credentials: ProviderCredentials, - log?: ExecutorLog | null, - onCredentialsRefreshed?: ExecuteInput["onCredentialsRefreshed"] - ): Promise { - if (!this.needsRefresh(credentials)) return credentials; - - try { - let persistRan = false; - const onPersist = onCredentialsRefreshed - ? async (refreshResult: Record) => { - persistRan = true; - await onCredentialsRefreshed(refreshResult as Partial); - } - : null; - - const refreshed = await runWithOnPersist(onPersist, () => - this.refreshCredentials(credentials, log || null) - ); - - if (!refreshed || isUnrecoverableRefreshError(refreshed)) { - return credentials; - } - - const merged = { ...credentials, ...refreshed }; - if (onCredentialsRefreshed && !persistRan) { - await onCredentialsRefreshed(refreshed); - } - return merged; - } catch (error) { - log?.error?.( - "TOKEN", - `Credential refresh failed for ${this.provider}: ${error instanceof Error ? error.message : String(error)}` - ); - return credentials; - } + buildUrl( + _model: string, + _stream: boolean, + _urlIndex = 0, + _credentials: ProviderCredentials | null = null + ) { + return GROK_BUILD_RESPONSES_URL; } async refreshCredentials( @@ -160,188 +161,58 @@ export class GrokCliExecutor extends BaseExecutor { const clientId = resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"); - try { - const body = new URLSearchParams({ - grant_type: "refresh_token", - client_id: clientId, - refresh_token: credentials.refreshToken, - }); - - const result = await this.nativeHttpsPost( - GROK_TOKEN_URL, - { - "Content-Type": "application/x-www-form-urlencoded", - }, - body.toString(), - 10_000 - ); - - if (result.status !== 200) { - log?.warn?.("TOKEN_REFRESH", `Grok Build: refresh failed with status ${result.status}`); - return null; - } - - const data = JSON.parse(result.body); - if (!data.access_token) { - log?.warn?.("TOKEN_REFRESH", "Grok Build: no access_token in refresh response"); - return null; - } - - const expiresIn = data.expires_in || 21600; - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); - - log?.info?.("TOKEN_REFRESH", `Grok Build: token refreshed, expires ${expiresAt}`); - - return { - accessToken: data.access_token, - refreshToken: data.refresh_token || credentials.refreshToken, - expiresAt, - }; - } catch (error) { - log?.warn?.( - "TOKEN_REFRESH", - `Grok Build: refresh error: ${error instanceof Error ? error.message : String(error)}` - ); - return null; - } - } - - private nativeHttpsPost( - url: string, - headers: Record, - bodyStr: string, - timeoutMs = 10_000 - ): Promise<{ status: number; body: string }> { - const urlObj = new URL(url); - const dispatch = resolveGrokRequestDispatch(url); - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => req.destroy(new Error("Timeout")), timeoutMs); - - const req = https.request( - { - hostname: urlObj.hostname, - port: 443, - path: urlObj.pathname + urlObj.search, - method: "POST", - ...(dispatch.family ? { family: dispatch.family } : {}), - ...(dispatch.agent ? { agent: dispatch.agent } : {}), - headers: { - ...headers, - "Content-Length": Buffer.byteLength(bodyStr), - }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", () => { - clearTimeout(timer); - resolve({ - status: res.statusCode ?? 500, - body: Buffer.concat(chunks).toString("utf-8"), - }); - }); - } - ); - - req.on("error", (err) => { - clearTimeout(timer); - reject(err); - }); - req.write(bodyStr); - req.end(); + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: credentials.refreshToken, }); - } - private nativePost( - url: string, - headers: Record, - bodyStr: string, - signal?: AbortSignal | null - ): Promise { - const urlObj = new URL(url); - const dispatch = resolveGrokRequestDispatch(url); + const providerData = credentials.providerSpecificData || {}; + const principalType = nonEmptyString(providerData.principalType); + const principalId = nonEmptyString(providerData.principalId); + if (principalType) body.set("principal_type", principalType); + if (principalId) body.set("principal_id", principalId); - if (signal?.aborted) { - return Promise.reject(new Error("Aborted")); - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => req.destroy(new Error("Timeout")), REQUEST_TIMEOUT_MS); - let settled = false; - - const settle = (fn: () => void) => { - if (settled) return; - settled = true; - clearTimeout(timer); - fn(); - }; - - const req = https.request( - { - hostname: urlObj.hostname, - port: 443, - path: urlObj.pathname + urlObj.search, - method: "POST", - ...(dispatch.family ? { family: dispatch.family } : {}), - ...(dispatch.agent ? { agent: dispatch.agent } : {}), - headers: { - ...headers, - "Content-Length": Buffer.byteLength(bodyStr), - }, - }, - (res) => { - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", () => { - settle(() => { - const responseBody = Buffer.concat(chunks).toString("utf-8"); - const responseHeaders: Record = {}; - for (const [key, value] of Object.entries(res.headers)) { - if (typeof value === "string") responseHeaders[key] = value; - else if (Array.isArray(value)) responseHeaders[key] = value.join(", "); - } - resolve( - new Response(responseBody, { - status: res.statusCode ?? 500, - headers: responseHeaders, - }) - ); - }); - }); - } - ); - - if (signal) { - const onAbort = () => settle(() => reject(new Error("Aborted"))); - signal.addEventListener("abort", onAbort, { once: true }); - // Clean up listener when request finishes naturally - req.on("close", () => signal.removeEventListener("abort", onAbort)); + for (let attempt = 1; attempt <= GROK_BUILD_REFRESH_MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + const delayMs = getRefreshRetryDelayMs(attempt - 1); + log?.debug?.( + "TOKEN_REFRESH", + `Grok Build: retrying token refresh (${attempt}/${GROK_BUILD_REFRESH_MAX_ATTEMPTS})` + ); + await new Promise((resolve) => setTimeout(resolve, delayMs)); } - req.on("error", (err) => settle(() => reject(err))); - req.write(bodyStr); - req.end(); - }); - } - - buildHeaders(credentials: ProviderCredentials, stream = true) { - const headers: Record = { - "Content-Type": "application/json", - }; - - if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } else if (credentials.apiKey) { - headers["Authorization"] = `Bearer ${credentials.apiKey}`; + const refreshed = await refreshGrokBuildCredentialsOnce(body, credentials, attempt, log); + if (refreshed !== undefined) return refreshed; } - headers["Accept"] = stream ? "text/event-stream" : "application/json"; - headers["x-grok-client-version"] = "0.2.72"; - headers["x-grok-client-identifier"] = "grok_cli_rs"; - headers["User-Agent"] = "grok-cli/0.2.72 (Windows 10.0.26200; x64)"; + return null; + } - return headers; + buildHeaders( + credentials: ProviderCredentials, + stream = true, + clientHeaders?: Record | null, + model?: string + ) { + const headers = super.buildHeaders(credentials, stream, clientHeaders, model); + const providerData = credentials.providerSpecificData || {}; + const principalType = nonEmptyString(providerData.principalType); + const sessionHeaders = getGrokBuildSessionHeaders({ + model, + stream, + userId: nonEmptyString(providerData.userId), + email: nonEmptyString(credentials.email) || nonEmptyString(providerData.email), + principalType, + }); + + // Preserve the standard GROK_CLI_USER_AGENT override produced by BaseExecutor. + if (headers["User-Agent"] || headers["user-agent"]) { + delete sessionHeaders["User-Agent"]; + } + + return { ...headers, ...sessionHeaders }; } transformRequest( @@ -350,36 +221,30 @@ export class GrokCliExecutor extends BaseExecutor { stream: boolean, _credentials: ProviderCredentials ) { - const transformed = - body && typeof body === "object" ? { ...(body as Record) } : {}; + const base = super.transformRequest(model, body, stream, _credentials); + const transformed = asRequestRecord(base); if (!transformed.model) { transformed.model = model || "grok-composer-2.5-fast"; } transformed.stream = !!stream; - // Grok Build rejects unsupported parameters with 400. `reasoning_effort`/`reasoning` - // are sent by clients like Claude Code (routing the Opus slot) but are not accepted - // by Grok Build's upstream chat-proxy endpoint — see #6288. - const UNSUPPORTED = [ - "presencePenalty", - "frequencyPenalty", - "logprobs", - "topLogprobs", - "reasoning_effort", - "reasoning", - ]; - for (const param of UNSUPPORTED) { - if (param in transformed) { - delete transformed[param]; - } + // Grok Build applies these Responses defaults before every request. + if (transformed.store === undefined) transformed.store = false; + transformed.include = ensureReasoningInclude(transformed.include); + + // OpenAI-compatible clients may carry fields the Grok Responses endpoint rejects. + stripUnsupportedGrokBuildParams(transformed); + + const reasoning = normalizeGrokBuildReasoning(transformed.reasoning, model); + if (reasoning) { + transformed.reasoning = reasoning; + } else { + delete transformed.reasoning; } - // xAI's cli-chat-proxy enforces a maximum of 200 tools per request and - // 400s above that ceiling. Clients that fan a large MCP toolset through - // Grok Build/Composer (e.g. Claude Code with many registered tools) can - // exceed it — cap defensively rather than let the request fail upstream. - if (Array.isArray(transformed.tools) && transformed.tools.length > MAX_TOOLS) { - transformed.tools = transformed.tools.slice(0, MAX_TOOLS); + // xAI's cli-chat-proxy rejects requests containing more than 200 tools. + if (Array.isArray(transformed.tools) && transformed.tools.length > GROK_BUILD_MAX_TOOLS) { + transformed.tools = transformed.tools.slice(0, GROK_BUILD_MAX_TOOLS); } return transformed; diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 5b228d053c..4037745ff0 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -55,6 +55,16 @@ const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth"]); */ const BROWSER_DEVICE_FLOW_PROVIDERS = new Set(["codex"]); +/** Device Code providers whose token grant does not use a PKCE verifier. */ +const NO_PKCE_DEVICE_CODE_PROVIDERS = new Set([ + "github", + "kimi-coding", + "kilocode", + "codebuddy-cn", + "grok-cli", + "ghe-copilot", +]); + /** * Providers whose PKCE flow has been retired but whose import-token path is * still active. Returning 410 Gone on `authorize` / `start-callback-server` / @@ -203,15 +213,11 @@ export async function GET( // Request device code (through proxy if configured) let deviceData; if ( - provider === "github" || + NO_PKCE_DEVICE_CODE_PROVIDERS.has(provider) || provider === "kiro" || - provider === "amazon-q" || - provider === "kimi-coding" || - provider === "kilocode" || - provider === "codebuddy-cn" || - provider === "ghe-copilot" + provider === "amazon-q" ) { - // GitHub, Kiro/Amazon Q, Kimi Coding, KiloCode, and GHE Copilot don't use PKCE for device code + // These providers don't use PKCE for device code. if (provider === "ghe-copilot" && gheUrl) { // GHE Copilot targets the enterprise host configured via gheUrl const providerOverrideConfig = { @@ -562,13 +568,8 @@ export async function POST( // Poll for token (through proxy if configured) let result; - if ( - provider === "github" || - provider === "kimi-coding" || - provider === "kilocode" || - provider === "codebuddy-cn" - ) { - // For providers that don't use PKCE (GitHub, Kimi Coding, KiloCode), don't pass codeVerifier + if (NO_PKCE_DEVICE_CODE_PROVIDERS.has(provider)) { + // Non-PKCE device providers do not receive a code verifier. result = await runWithProxyContextOrDirect(proxy, () => (pollForToken as any)(provider, deviceCode) ); diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 9525af54f9..cd767b85c5 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -1,4 +1,9 @@ import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts"; +import { + GROK_BUILD_DEFAULT_CONTEXT_WINDOW, + getGrokBuildModelsHeaders, + GROK_BUILD_MODELS_URL, +} from "@omniroute/open-sse/config/grokBuild.ts"; import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; import { filterClinepassModels } from "@omniroute/open-sse/services/clinepassModels.ts"; @@ -76,6 +81,11 @@ export function parseAlibabaModelStudioModels(data: any): any[] { export function parseQwenCloudTextModels(data: any): any[] { return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS); } +type ProviderModelsHeaderContext = { + authType?: string; + providerSpecificData?: unknown; + email?: string | null; +}; export type ProviderModelsConfigEntry = { url: string; @@ -85,7 +95,10 @@ export type ProviderModelsConfigEntry = { authPrefix?: string; authQuery?: string; body?: unknown; - buildHeaders?: (token: string, connection?: any) => Record; + buildHeaders?: ( + token: string, + connection?: ProviderModelsHeaderContext + ) => Record; parseResponse: (data: any) => any; }; @@ -175,6 +188,118 @@ export function parseKimiCodingModels(data: any): any[] { .map(normalizeKimiCodingModel); } +type GrokBuildModelRecord = Record; + +function asGrokBuildRecord(value: unknown): GrokBuildModelRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as GrokBuildModelRecord) + : {}; +} + +function grokBuildString(...values: unknown[]): string | undefined { + return values + .find((value): value is string => typeof value === "string" && value.trim().length > 0) + ?.trim(); +} + +function grokBuildPositiveNumber(...values: unknown[]): number | undefined { + return values.find( + (value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0 + ); +} + +function getGrokBuildModelItems(data: unknown): unknown[] { + const envelope = asGrokBuildRecord(data); + if (Array.isArray(data)) return data; + if (Array.isArray(envelope.data)) return envelope.data; + return Array.isArray(envelope.models) ? envelope.models : []; +} + +function hasGrokBuildReasoning(model: GrokBuildModelRecord, metadata: GrokBuildModelRecord) { + const flags = [ + model.supportsReasoningEffort, + model.supports_reasoning_effort, + metadata.supportsReasoningEffort, + metadata.supports_reasoning_effort, + ]; + const effortLists = [ + model.reasoningEfforts, + model.reasoning_efforts, + metadata.reasoningEfforts, + metadata.reasoning_efforts, + ]; + return ( + flags.some((value) => value === true) || + grokBuildString( + model.reasoningEffort, + model.reasoning_effort, + metadata.reasoningEffort, + metadata.reasoning_effort + ) !== undefined || + effortLists.some((value) => Array.isArray(value) && value.length > 0) + ); +} + +function normalizeGrokBuildModel(value: unknown): GrokBuildModelRecord | null { + const model = asGrokBuildRecord(value); + const metadata = asGrokBuildRecord(model._meta); + const catalogId = grokBuildString(model.id); + const id = grokBuildString( + model.model, + model.modelId, + catalogId, + metadata.model, + metadata.modelId + ); + const hidden = model.hidden === true || metadata.hidden === true; + // grok-cli always uses OAuth session auth. Official Grok Build visibility + // keeps supported_in_api=false models available to session users and only + // hides them from API-key users. + if (!id || hidden) return null; + + const backend = grokBuildString( + model.apiBackend, + model.api_backend, + metadata.apiBackend, + metadata.api_backend + ); + // This provider currently executes against /v1/responses. Grok Build can + // advertise chat_completions or messages backends too, but exposing those + // here would route their request shape to the wrong upstream endpoint. + if (backend !== "responses") return null; + + const inputTokenLimit = + grokBuildPositiveNumber( + model.contextWindow, + model.context_window, + metadata.contextWindow, + metadata.totalContextTokens + ) || GROK_BUILD_DEFAULT_CONTEXT_WINDOW; + const outputTokenLimit = grokBuildPositiveNumber( + model.maxCompletionTokens, + model.max_completion_tokens + ); + const description = grokBuildString(model.description); + + return { + id, + name: grokBuildString(model.name, id) || id, + owned_by: "grok-cli", + ...(description ? { description } : {}), + inputTokenLimit, + ...(outputTokenLimit ? { outputTokenLimit } : {}), + ...(hasGrokBuildReasoning(model, metadata) ? { supportsThinking: true } : {}), + apiFormat: "responses", + supportedEndpoints: ["responses"], + }; +} + +function parseGrokBuildModels(data: unknown): GrokBuildModelRecord[] { + return getGrokBuildModelItems(data) + .map(normalizeGrokBuildModel) + .filter((model): model is GrokBuildModelRecord => model !== null); +} + const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = { url: KIMI_CODING_MODELS_URL, method: "GET", @@ -316,6 +441,21 @@ export const PROVIDER_MODELS_CONFIG: Record = authPrefix: "Bearer ", parseResponse: (data) => data.data || [], }, + "grok-cli": { + url: GROK_BUILD_MODELS_URL, + method: "GET", + headers: {}, + buildHeaders: (token, context) => { + const providerData = asGrokBuildRecord(context?.providerSpecificData); + return getGrokBuildModelsHeaders({ + token, + userId: grokBuildString(providerData.userId), + email: grokBuildString(context?.email, providerData.email), + principalType: grokBuildString(providerData.principalType), + }); + }, + parseResponse: parseGrokBuildModels, + }, openrouter: { url: "https://openrouter.ai/api/v1/models", method: "GET", diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index da06437827..e7dfe6df45 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -13,6 +13,12 @@ import { GITHUB_COPILOT_CHAT_USER_AGENT, GITHUB_COPILOT_EDITOR_VERSION, } from "@omniroute/open-sse/config/providerHeaderProfiles.ts"; +import { + GROK_BUILD_DEVICE_CODE_URL, + GROK_BUILD_OAUTH_ISSUER, + GROK_BUILD_OAUTH_SCOPES, + GROK_BUILD_TOKEN_URL, +} from "@omniroute/open-sse/config/grokBuild.ts"; import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts"; import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "../gitlab"; @@ -110,11 +116,14 @@ export const CODEBUDDY_CN_CONFIG = { pollInterval: 5000, }; -// Grok Build (xAI) OAuth Configuration (Import-Token Flow with refresh) +// Grok Build (xAI) OAuth Configuration (Device Code + import-token fallback) // Public client_id resolved through resolvePublicCred so it is never a literal. export const GROK_CLI_CONFIG = { clientId: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), - tokenUrl: "https://auth.x.ai/oauth2/token", + issuer: GROK_BUILD_OAUTH_ISSUER, + deviceCodeUrl: GROK_BUILD_DEVICE_CODE_URL, + tokenUrl: GROK_BUILD_TOKEN_URL, + scope: GROK_BUILD_OAUTH_SCOPES.join(" "), }; // xAI API OAuth Configuration (Authorization Code Flow with PKCE) diff --git a/src/lib/oauth/providers/grok-cli.ts b/src/lib/oauth/providers/grok-cli.ts index 59c7cf4c80..313307c18f 100644 --- a/src/lib/oauth/providers/grok-cli.ts +++ b/src/lib/oauth/providers/grok-cli.ts @@ -1,11 +1,16 @@ /** - * Grok Build OAuth Provider — Import Token Flow with Refresh Support + * Grok Build OAuth Provider — Device Code Flow with Import Token Fallback * * User pastes the entire auth.json from ~/.grok/auth.json * or just the JWT access token string. * Supports automatic token refresh using the refresh_token. */ +import { + getGrokBuildOAuthHeaders, + GROK_BUILD_OAUTH_ISSUER, + GROK_BUILD_OAUTH_REFERRER, +} from "@omniroute/open-sse/config/grokBuild.ts"; import { GROK_CLI_CONFIG } from "../constants/oauth"; interface GrokCliAuthInfo { @@ -14,43 +19,179 @@ interface GrokCliAuthInfo { team_id: string; tier: number; principal_type: string; + principal_id: string; + organization_id: string; } -function parseJwtPayload(token: string): { +const EMPTY_STANDARD_TOKEN_FIELDS = { + idToken: null, + tokenType: null, + scope: null, + oauthExpiresIn: null, +} as const; + +async function parseOAuthResponse(response: Response): Promise> { + try { + const value = await response.json(); + return value && typeof value === "object" ? (value as Record) : {}; + } catch { + return { + error: "invalid_response", + error_description: "xAI returned a non-JSON OAuth response", + }; + } +} + +function validateVerificationUri(value: string): void { + if ( + [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error("Grok returned an invalid verification URL"); + } + + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Grok returned an invalid verification URL"); + } + + const isLocalHttp = + url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1"); + if (url.protocol !== "https:" && !isLocalHttp) { + throw new Error("Grok returned an unsupported verification URL"); + } +} + +async function requestDeviceCode(config: typeof GROK_CLI_CONFIG) { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: getGrokBuildOAuthHeaders("ui"), + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + referrer: GROK_BUILD_OAUTH_REFERRER, + }), + }); + const data = await parseOAuthResponse(response); + + if (!response.ok) { + throw new Error( + typeof data.error_description === "string" + ? data.error_description + : "Grok device authorization failed" + ); + } + if ( + typeof data.device_code !== "string" || + typeof data.user_code !== "string" || + typeof data.verification_uri !== "string" + ) { + throw new Error("Grok device authorization response is incomplete"); + } + if (!/^[A-Za-z0-9-]+$/.test(data.user_code)) { + throw new Error("Grok returned an invalid device code"); + } + validateVerificationUri(data.verification_uri); + if (typeof data.verification_uri_complete === "string") { + validateVerificationUri(data.verification_uri_complete); + } + + return { + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri, + verification_uri_complete: + typeof data.verification_uri_complete === "string" + ? data.verification_uri_complete + : data.verification_uri, + expires_in: typeof data.expires_in === "number" ? data.expires_in : 1800, + interval: typeof data.interval === "number" ? data.interval : 5, + }; +} + +async function pollToken(config: typeof GROK_CLI_CONFIG, deviceCode: string) { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: getGrokBuildOAuthHeaders("ui"), + body: new URLSearchParams({ + client_id: config.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + return { ok: response.ok, data: await parseOAuthResponse(response) }; +} + +type ParsedGrokJwt = { email: string | null; authInfo: GrokCliAuthInfo | null; exp: number | null; -} { - try { - const parts = token.split("."); - if (parts.length !== 3) return { email: null, authInfo: null, exp: null }; +}; - let base64 = parts[1]; - switch (base64.length % 4) { - case 2: - base64 += "=="; - break; - case 3: - base64 += "="; - break; - } - base64 = base64.replace(/-/g, "+").replace(/_/g, "/"); +function emptyGrokJwt(): ParsedGrokJwt { + return { email: null, authInfo: null, exp: null }; +} - const payload = JSON.parse(Buffer.from(base64, "base64").toString("utf-8")); - return { - email: payload.email || null, - authInfo: { - user_id: payload.sub || "", - email: payload.email || "", - team_id: payload.team_id || "", - tier: payload.tier || 1, - principal_type: payload.principal_type || "User", - }, - exp: typeof payload.exp === "number" ? payload.exp : null, - }; - } catch { - return { email: null, authInfo: null, exp: null }; +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + + let base64 = parts[1]; + switch (base64.length % 4) { + case 2: + base64 += "=="; + break; + case 3: + base64 += "="; + break; } + base64 = base64.replace(/-/g, "+").replace(/_/g, "/"); + + try { + const payload = JSON.parse(Buffer.from(base64, "base64").toString("utf-8")); + return payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; + } catch { + return null; + } +} + +function jwtString(payload: Record, key: string): string { + return typeof payload[key] === "string" ? payload[key] : ""; +} + +function parseJwtPayload(token: string): ParsedGrokJwt { + const payload = decodeJwtPayload(token); + if (!payload) return emptyGrokJwt(); + + const principalType = jwtString(payload, "principal_type"); + const principalId = jwtString(payload, "principal_id"); + const normalizedPrincipalType = principalType.toLowerCase(); + const isTeamPrincipal = normalizedPrincipalType === "team" && principalId.length > 0; + const isOrganizationPrincipal = + normalizedPrincipalType === "organization" && principalId.length > 0; + const email = jwtString(payload, "email"); + + return { + email: email || null, + authInfo: { + user_id: isTeamPrincipal || isOrganizationPrincipal ? principalId : jwtString(payload, "sub"), + email, + team_id: jwtString(payload, "team_id") || (isTeamPrincipal ? principalId : ""), + tier: (payload.tier as number) || 1, + principal_type: principalType, + principal_id: principalId, + organization_id: + jwtString(payload, "organization_id") || (isOrganizationPrincipal ? principalId : ""), + }, + exp: typeof payload.exp === "number" ? payload.exp : null, + }; } /** @@ -62,16 +203,42 @@ function parseJwtPayload(token: string): { function extractTokenAndRefresh(input: unknown): { accessToken: string; refreshToken: string | null; + idToken: string | null; + tokenType: string | null; + scope: string | null; + oauthExpiresIn: number | null; rawAuthJson: Record | null; expiresAt: string | null; } { // Direct JWT string if (typeof input === "string") - return { accessToken: input, refreshToken: null, rawAuthJson: null, expiresAt: null }; + return { + ...EMPTY_STANDARD_TOKEN_FIELDS, + accessToken: input, + refreshToken: null, + rawAuthJson: null, + expiresAt: null, + }; if (input && typeof input === "object") { const obj = input as Record; + if (typeof obj.access_token === "string" && obj.access_token.length > 0) { + return { + accessToken: obj.access_token, + refreshToken: typeof obj.refresh_token === "string" ? obj.refresh_token : null, + idToken: typeof obj.id_token === "string" ? obj.id_token : null, + tokenType: typeof obj.token_type === "string" ? obj.token_type : null, + scope: typeof obj.scope === "string" ? obj.scope : null, + oauthExpiresIn: + typeof obj.expires_in === "number" && Number.isFinite(obj.expires_in) + ? obj.expires_in + : null, + rawAuthJson: null, + expiresAt: null, + }; + } + // The route handler wraps the token: { accessToken: }. // Unwrap once before checking the inner value. const inner = @@ -81,13 +248,18 @@ function extractTokenAndRefresh(input: unknown): { // auth.json format: { "https://auth.x.ai::...": { key: "eyJ...", refresh_token: "..." } } if (inner && typeof inner === "object") { + const preferredScope = `${GROK_BUILD_OAUTH_ISSUER}::${GROK_CLI_CONFIG.clientId}`; const innerKeys = Object.keys(inner); - for (const k of innerKeys) { + const orderedKeys = innerKeys.includes(preferredScope) + ? [preferredScope, ...innerKeys.filter((key) => key !== preferredScope)] + : innerKeys; + for (const k of orderedKeys) { const entry = inner[k]; if (entry && typeof entry === "object" && "key" in entry) { const e = entry as Record; if (typeof e.key === "string" && e.key.startsWith("eyJ")) { return { + ...EMPTY_STANDARD_TOKEN_FIELDS, accessToken: e.key, refreshToken: typeof e.refresh_token === "string" ? e.refresh_token : null, rawAuthJson: inner as Record, @@ -101,6 +273,7 @@ function extractTokenAndRefresh(input: unknown): { // Raw JWT passed as { accessToken: "eyJ..." } if (typeof obj.accessToken === "string" && obj.accessToken.length > 0) { return { + ...EMPTY_STANDARD_TOKEN_FIELDS, accessToken: obj.accessToken, refreshToken: typeof obj.refreshToken === "string" ? obj.refreshToken : null, rawAuthJson: null, @@ -109,46 +282,96 @@ function extractTokenAndRefresh(input: unknown): { } } - return { accessToken: "", refreshToken: null, rawAuthJson: null, expiresAt: null }; + return { + ...EMPTY_STANDARD_TOKEN_FIELDS, + accessToken: "", + refreshToken: null, + rawAuthJson: null, + expiresAt: null, + }; +} + +type ExtractedGrokToken = ReturnType; + +function firstString(...values: Array): string | null { + return values.find((value) => Boolean(value)) || null; +} + +function firstAuthInfoString( + primaryClaims: ParsedGrokJwt, + secondaryClaims: ParsedGrokJwt, + key: Exclude +): string | null { + return firstString(primaryClaims.authInfo?.[key], secondaryClaims.authInfo?.[key]); +} + +function resolveGrokIdentity(accessClaims: ParsedGrokJwt, idClaims: ParsedGrokJwt) { + const principalType = firstAuthInfoString(accessClaims, idClaims, "principal_type"); + const principalId = firstAuthInfoString(accessClaims, idClaims, "principal_id"); + const normalizedPrincipalType = principalType?.toLowerCase(); + const isTeamPrincipal = normalizedPrincipalType === "team" && Boolean(principalId); + const isOrganizationPrincipal = + normalizedPrincipalType === "organization" && Boolean(principalId); + + return { + principalType, + principalId, + email: firstString(idClaims.email, accessClaims.email), + userId: + isTeamPrincipal || isOrganizationPrincipal + ? principalId + : firstAuthInfoString(idClaims, accessClaims, "user_id"), + teamId: isTeamPrincipal ? principalId : firstAuthInfoString(accessClaims, idClaims, "team_id"), + organizationId: isOrganizationPrincipal + ? principalId + : firstAuthInfoString(accessClaims, idClaims, "organization_id"), + }; +} + +function resolveGrokExpiresIn(extracted: ExtractedGrokToken, accessClaims: ParsedGrokJwt): number { + const currentSec = Math.floor(Date.now() / 1000); + let expiresIn = extracted.oauthExpiresIn ?? 21600; + + if (extracted.oauthExpiresIn == null && extracted.expiresAt) { + const parsed = Date.parse(extracted.expiresAt); + if (!isNaN(parsed)) expiresIn = Math.floor(parsed / 1000) - currentSec; + } else if (extracted.oauthExpiresIn == null && accessClaims.exp) { + expiresIn = accessClaims.exp - currentSec; + } + + // Keep an already-expired token eligible for the refresh path. + return Math.max(1, expiresIn); } export const grokCli = { config: GROK_CLI_CONFIG, - flowType: "import_token", - mapTokens: (token: unknown, extra?: unknown) => { - const { accessToken, refreshToken, rawAuthJson, expiresAt } = extractTokenAndRefresh(token); - const { email, authInfo, exp } = parseJwtPayload(accessToken); - - const currentSec = Math.floor(Date.now() / 1000); - let expiresIn = 21600; - - if (expiresAt) { - const parsed = Date.parse(expiresAt); - if (!isNaN(parsed)) { - expiresIn = Math.floor(parsed / 1000) - currentSec; - } - } else if (typeof exp === "number" && exp > 0) { - expiresIn = exp - currentSec; - } - - // #5775 follow-up: guard against an already-expired token yielding a negative - // expiresIn. A negative value is truthy downstream (import-token route) and maps - // to a PAST expiresAt, which AutoCombo reads as "already expired" and excludes the - // connection instead of refreshing it. Clamp to a tiny positive TTL so the token is - // treated as due-for-refresh. - expiresIn = Math.max(1, expiresIn); + flowType: "device_code", + requestDeviceCode, + pollToken, + mapTokens: (token: unknown, _extra?: unknown) => { + const extracted = extractTokenAndRefresh(token); + const accessClaims = parseJwtPayload(extracted.accessToken); + const idClaims = extracted.idToken ? parseJwtPayload(extracted.idToken) : emptyGrokJwt(); + const identity = resolveGrokIdentity(accessClaims, idClaims); + const expiresIn = resolveGrokExpiresIn(extracted, accessClaims); return { - accessToken, - refreshToken, + accessToken: extracted.accessToken, + refreshToken: extracted.refreshToken, + idToken: extracted.idToken, expiresIn, - email, + tokenType: extracted.tokenType, + scope: extracted.scope, + email: identity.email, providerSpecificData: { - userId: authInfo?.user_id || null, - teamId: authInfo?.team_id || null, - tier: authInfo?.tier || 1, - principalType: authInfo?.principal_type || "User", - rawAuthJson: rawAuthJson || undefined, + userId: identity.userId, + email: identity.email, + teamId: identity.teamId, + tier: accessClaims.authInfo?.tier || idClaims.authInfo?.tier || 1, + principalType: identity.principalType, + principalId: identity.principalId, + organizationId: identity.organizationId, + rawAuthJson: extracted.rawAuthJson || undefined, }, }; }, diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 44a6e364ef..f8c7147bfb 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -6,7 +6,7 @@ import Modal from "./Modal"; import Button from "./Button"; import Input from "./Input"; import LinkifiedText from "./LinkifiedText"; -import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { OAuthDeviceCodePanel, OAuthManualInputPanel } from "./OAuthModalPanels"; import { parseResponseBody, getErrorMessage } from "@/shared/utils/api"; import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit"; import { @@ -15,18 +15,26 @@ import { } from "@/lib/oauth/utils/codexSessionImport"; import GheConfigStep from "@/shared/components/oauthModal/GheConfigStep"; +export { formatDeviceCodeRemaining } from "./OAuthModalPanels"; + const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy"]); /** Providers that use a local callback server on a random port (PKCE browser flow). */ const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex", "xai-oauth"]); -/** - * Phase 1 hotfix (2026-05-29): windsurf & devin-cli only support import-token. - * Their PKCE flow targeting app.devin.ai/editor/signin returned 404 post-rebrand. - * Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser. - * Spec: _tasks/superpowers/specs/2026-05-29-windsurf-login-fix-design.md. - */ -const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]); +const DEVICE_CODE_PROVIDERS = new Set([ + "github", + "kiro", + "amazon-q", + "kimi-coding", + "kilocode", + "codebuddy-cn", + "grok-cli", + "ghe-copilot", +]); + +const TOKEN_PASTE_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]); +const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli"]); // POST a bare Codex access token to the access-token-only import endpoint // (#1290); shared by the bare-JWT and session-JSON paste branches (#6636). @@ -59,6 +67,39 @@ type OAuthModalProps = { reauthConnection?: null | { id?: string }; }; +type DevicePollResult = + { status: "pending" | "slow_down" | "success" } | { status: "error"; message: string }; + +function positiveNumberOr(value: unknown, fallback: number): number { + return Math.max(1, Number(value) || fallback); +} + +async function pollDeviceCodeOnce( + provider: string | undefined, + payload: Record +): Promise { + try { + const res = await fetch(`/api/oauth/${provider}/poll`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = (await parseResponseBody(res)) as Record; + + if (data.success) return { status: "success" }; + if (data.error === "slow_down") return { status: "slow_down" }; + if (data.error && !data.pending) { + return { status: "error", message: String(data.errorDescription || data.error) }; + } + return { status: "pending" }; + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : "Authorization failed", + }; + } +} + /** * OAuth Modal Component * - Localhost: Auto callback via popup message @@ -82,20 +123,17 @@ export default function OAuthModal({ const [deviceData, setDeviceData] = useState(null); const [gheUrl, setGheUrl] = useState(""); const [polling, setPolling] = useState(false); - // API-key paste mode: for providers that accept a token directly (windsurf, devin-cli) - const [showPasteToken, setShowPasteToken] = useState( - provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli" - ); + const [deviceCodeExpiresAt, setDeviceCodeExpiresAt] = useState(null); + const [deviceCodeSecondsRemaining, setDeviceCodeSecondsRemaining] = useState(null); + // API-key paste mode for direct-token providers. + const [showPasteToken, setShowPasteToken] = useState(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider)); const [pasteToken, setPasteToken] = useState(""); const [savingToken, setSavingToken] = useState(false); - const supportsTokenPaste = - provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli"; - // Phase 1 hotfix (2026-05-29): windsurf/devin-cli are import-token-only. - // Hide the "Browser Login" tab — Phase 2 will restore it via Firebase OAuth. + const supportsTokenPaste = TOKEN_PASTE_PROVIDERS.has(provider); const importTokenOnly = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider); const popupRef = useRef(null); - const { copied, copy } = useCopyToClipboard(); + const deviceFlowRunRef = useRef(0); const deviceVerificationUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || ""; @@ -129,6 +167,13 @@ export default function OAuthModal({ const callbackProcessedRef = useRef(false); const flowStartedRef = useRef(false); + const invalidateDeviceFlow = useCallback(() => { + deviceFlowRunRef.current += 1; + setPolling(false); + setDeviceCodeExpiresAt(null); + setDeviceCodeSecondsRemaining(null); + }, []); + // Define all useCallback hooks BEFORE the useEffects that reference them // Exchange tokens @@ -234,52 +279,56 @@ export default function OAuthModal({ // Poll for device code token const startPolling = useCallback( - async (deviceCode, codeVerifier, interval, extraData) => { + async (deviceCode, codeVerifier, interval, expiresIn, extraData) => { + const runId = ++deviceFlowRunRef.current; + const safeInterval = positiveNumberOr(interval, 5); + const safeExpiresIn = positiveNumberOr(expiresIn, safeInterval * 60); + const deadline = Date.now() + safeExpiresIn * 1000; + let currentInterval = safeInterval; + setPolling(true); - const maxAttempts = 60; + setDeviceCodeExpiresAt(deadline); - for (let i = 0; i < maxAttempts; i++) { - await new Promise((r) => setTimeout(r, interval * 1000)); + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000)); + if (runId !== deviceFlowRunRef.current || Date.now() >= deadline) break; - try { - const res = await fetch(`/api/oauth/${provider}/poll`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - deviceCode, - connectionId: reauthConnection?.id, - codeVerifier, - extraData, - }), - }); + const result = await pollDeviceCodeOnce(provider, { + deviceCode, + connectionId: reauthConnection?.id, + codeVerifier, + extraData, + }); + if (runId !== deviceFlowRunRef.current) return; - const data = (await parseResponseBody(res)) as Record; + if (result.status === "success") { + setStep("success"); + setPolling(false); + setDeviceCodeExpiresAt(null); + onSuccess?.(); + return; + } - if (data.success) { - setStep("success"); - setPolling(false); - onSuccess?.(); - return; - } + if (result.status === "slow_down") { + currentInterval = Math.min(currentInterval + 5, 30); + continue; + } - if (data.error === "expired_token" || data.error === "access_denied") { - throw new Error(data.errorDescription || data.error); - } - - if (data.error === "slow_down") { - interval = Math.min(interval + 5, 30); - } - } catch (err) { - setError(err.message); + if (result.status === "error") { + setError(result.message); setStep("error"); setPolling(false); + setDeviceCodeExpiresAt(null); return; } } - setError("Authorization timeout"); - setStep("error"); - setPolling(false); + if (runId === deviceFlowRunRef.current) { + setError("Authorization timeout"); + setStep("error"); + setPolling(false); + setDeviceCodeExpiresAt(null); + } }, [provider, onSuccess, reauthConnection] ); @@ -290,17 +339,11 @@ export default function OAuthModal({ try { setError(null); - // Device code flow (GitHub, Kiro, Kimi Coding, KiloCode, GHE Copilot) - if ( - provider === "github" || - provider === "ghe-copilot" || - provider === "kiro" || - provider === "amazon-q" || - provider === "kimi-coding" || - provider === "kilocode" || - provider === "codebuddy-cn" - ) { + // Device code flow + if (DEVICE_CODE_PROVIDERS.has(provider)) { + invalidateDeviceFlow(); setIsDeviceCode(true); + setDeviceData(null); setStep("waiting"); // GHE Copilot needs the enterprise URL collected first (see ghe-config step) @@ -338,7 +381,7 @@ export default function OAuthModal({ // Open verification URL const verifyUrl = data.verification_uri_complete || data.verification_uri; - if (verifyUrl) window.open(verifyUrl, "oauth_verify"); + if (typeof verifyUrl === "string" && verifyUrl) window.open(verifyUrl, "oauth_verify"); // Start polling - pass extraData for Kiro (contains _clientId, _clientSecret) const extraData = @@ -351,7 +394,13 @@ export default function OAuthModal({ : provider === "ghe-copilot" && gheUrl.trim() ? { gheUrl: gheUrl.trim() } : null; - startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData); + startPolling( + data.device_code, + data.codeVerifier, + data.interval || 5, + data.expires_in, + extraData + ); return; } @@ -519,29 +568,57 @@ export default function OAuthModal({ reauthConnection, idcConfig, gheUrl, + invalidateDeviceFlow, ]); - // Reset guard when modal closes + useEffect(() => { + if (!deviceCodeExpiresAt) { + setDeviceCodeSecondsRemaining(null); + return; + } + + const updateRemaining = () => { + setDeviceCodeSecondsRemaining( + Math.max(0, Math.ceil((deviceCodeExpiresAt - Date.now()) / 1000)) + ); + }; + updateRemaining(); + const timer = window.setInterval(updateRemaining, 1000); + return () => window.clearInterval(timer); + }, [deviceCodeExpiresAt]); + + useEffect(() => { + invalidateDeviceFlow(); + flowStartedRef.current = false; + }, [provider, invalidateDeviceFlow]); + useEffect(() => { if (!isOpen) { + invalidateDeviceFlow(); flowStartedRef.current = false; } - }, [isOpen]); + }, [isOpen, invalidateDeviceFlow]); + + useEffect( + () => () => { + deviceFlowRunRef.current += 1; + }, + [] + ); // Reset state and start OAuth when modal opens useEffect(() => { - if (isOpen && provider) { - if (flowStartedRef.current) return; // Already started, prevent duplicate - flowStartedRef.current = true; - setAuthData(null); - setCallbackUrl(""); - setError(null); - setIsDeviceCode(false); - setDeviceData(null); - setPolling(false); - // Auto start OAuth - startOAuthFlow(); - } + if (!isOpen || !provider || flowStartedRef.current) return; + flowStartedRef.current = true; + const startsInPasteMode = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider); + setShowPasteToken(startsInPasteMode); + setAuthData(null); + setCallbackUrl(""); + setError(null); + setIsDeviceCode(false); + setDeviceData(null); + setPolling(false); + if (!startsInPasteMode) startOAuthFlow(); }, [isOpen, provider, startOAuthFlow]); // Listen for OAuth callback via multiple methods @@ -774,32 +851,45 @@ export default function OAuthModal({ } }; + const handleClose = useCallback(() => { + invalidateDeviceFlow(); + onClose(); + }, [invalidateDeviceFlow, onClose]); + + const handlePasteMode = useCallback(() => { + invalidateDeviceFlow(); + setShowPasteToken(true); + }, [invalidateDeviceFlow]); + + const handleBrowserMode = useCallback(() => { + setShowPasteToken(false); + startOAuthFlow(); + }, [startOAuthFlow]); + if (!provider || !providerInfo) return null; return (
- {/* Paste-token tab toggle (Windsurf / Devin CLI only). - Phase 1 hotfix: when importTokenOnly is true, hide the entire toggle — - there is no "Browser Login" tab to switch to until Phase 2 ships. */} + {/* Browser login with an optional token-import fallback. */} {supportsTokenPaste && !importTokenOnly && step !== "success" && (
)} @@ -830,7 +920,7 @@ export default function OAuthModal({ > {savingToken ? "Saving…" : "Save Connection"} -
@@ -870,142 +960,28 @@ export default function OAuthModal({ {/* Device Code Flow - Waiting */} {step === "waiting" && isDeviceCode && deviceData && ( - <> -
-

{t("deviceCodeVisitUrl")}

-
-

{t("deviceCodeVerificationUrl")}

-
- {deviceVerificationUrl} -
-
-
-

{t("deviceCodeYourCode")}

-
-

- {deviceData.user_code} -

-
-
-
- {polling && ( -
- - progress_activity - - {t("deviceCodeWaiting")} -
- )} - + )} {/* Manual Input Step */} {step === "input" && !isDeviceCode && ( - <> -
- {/* Remote/LAN server info for Google OAuth providers */} - {!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && ( -
- - warning - - - {t.rich("googleOAuthWarning", { - code: (c) => {c}, - a: (c) => ( - - {c} - - ), - })} - -
- )} - {/* Actionable remote paste instruction — shown for ALL remote providers, - including Google OAuth (antigravity/agy). The Google - loopback creds redirect to 127.0.0.1:/callback, which on a - remotely-accessed dashboard lands on the operator's own machine and - shows a "can't reach this page" error. That is expected: the URL bar - still carries ?code=…, and pasting it below completes the login. Before - this, Google providers only saw the discouraging loopback warning and - never the "copy the URL and paste it" step, so remote login appeared to - hang. */} - {!isTrueLocalhost && ( -
- - info - - {t("remoteAccessInfo")} -
- )} -
-

{t("step1OpenUrl")}

-
- - -
-
- -
-

{t("step2PasteCallback")}

-

- {t.rich("step2Hint", { - code: (c) => {c}, - })} -

- setCallbackUrl(e.target.value)} - placeholder={ - provider === "claude" || provider === "cline" - ? "code#state or /callback?code=..." - : placeholderUrl - } - className="font-mono text-xs" - /> -
-
- -
- - -
- + )} )} @@ -1022,7 +998,7 @@ export default function OAuthModal({

{t("successMessage", { providerName: providerInfo.name })}

- @@ -1042,7 +1018,7 @@ export default function OAuthModal({ - diff --git a/src/shared/components/OAuthModalPanels.tsx b/src/shared/components/OAuthModalPanels.tsx new file mode 100644 index 0000000000..aaa3d23e87 --- /dev/null +++ b/src/shared/components/OAuthModalPanels.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +import Button from "./Button"; +import Input from "./Input"; + +export function formatDeviceCodeRemaining(seconds: number): string { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safeSeconds / 60); + return `${minutes}:${String(safeSeconds % 60).padStart(2, "0")}`; +} + +type OAuthDeviceCodePanelProps = { + deviceData: { user_code: string }; + verificationUrl: string; + secondsRemaining: number | null; + polling: boolean; +}; + +export function OAuthDeviceCodePanel({ + deviceData, + verificationUrl, + secondsRemaining, + polling, +}: OAuthDeviceCodePanelProps) { + const t = useTranslations("oauthModal"); + const { copied, copy } = useCopyToClipboard(); + + return ( + <> +
+

{t("deviceCodeVisitUrl")}

+
+

{t("deviceCodeVerificationUrl")}

+
+ + {verificationUrl} + +
+
+
+

{t("deviceCodeYourCode")}

+
+

{deviceData.user_code}

+
+ {secondsRemaining !== null && ( +
+ schedule + {formatDeviceCodeRemaining(secondsRemaining)} +
+ )} +
+
+ {polling && ( +
+ progress_activity + {t("deviceCodeWaiting")} +
+ )} + + ); +} + +function OAuthRemoteAccessNotices({ + isGoogleOAuth, + isTrueLocalhost, +}: { + isGoogleOAuth: boolean; + isTrueLocalhost: boolean; +}) { + const t = useTranslations("oauthModal"); + if (isTrueLocalhost) return null; + + return ( + <> + {isGoogleOAuth && ( +
+ warning + + {t.rich("googleOAuthWarning", { + code: (chunks) => {chunks}, + a: (chunks) => ( + + {chunks} + + ), + })} + +
+ )} +
+ info + {t("remoteAccessInfo")} +
+ + ); +} + +type OAuthManualInputPanelProps = { + provider: string; + isGoogleOAuth: boolean; + isTrueLocalhost: boolean; + authUrl: string; + callbackUrl: string; + placeholderUrl: string; + canSubmit: boolean; + onCallbackUrlChange: (value: string) => void; + onSubmit: () => void | Promise; + onClose: () => void; +}; + +export function OAuthManualInputPanel({ + provider, + isGoogleOAuth, + isTrueLocalhost, + authUrl, + callbackUrl, + placeholderUrl, + canSubmit, + onCallbackUrlChange, + onSubmit, + onClose, +}: OAuthManualInputPanelProps) { + const t = useTranslations("oauthModal"); + const { copied, copy } = useCopyToClipboard(); + + return ( + <> +
+ +
+

{t("step1OpenUrl")}

+
+ + +
+
+
+

{t("step2PasteCallback")}

+

+ {t.rich("step2Hint", { + code: (chunks) => {chunks}, + })} +

+ onCallbackUrlChange(event.target.value)} + placeholder={ + provider === "claude" || provider === "cline" + ? "code#state or /callback?code=..." + : placeholderUrl + } + className="font-mono text-xs" + /> +
+
+
+ + +
+ + ); +} diff --git a/tests/unit/grok-cli-device-route.test.ts b/tests/unit/grok-cli-device-route.test.ts new file mode 100644 index 0000000000..fc926a946c --- /dev/null +++ b/tests/unit/grok-cli-device-route.test.ts @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-grok-device-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const route = await import("../../src/app/api/oauth/[provider]/[action]/route.ts"); + +const originalFetch = globalThis.fetch; + +test.before(async () => { + await settingsDb.updateSettings({ requireLogin: false }); +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("grok-cli poll does not require a PKCE code verifier", async () => { + let upstreamBody = ""; + globalThis.fetch = (async (_input, init) => { + upstreamBody = String(init?.body); + return new Response( + JSON.stringify({ + error: "authorization_pending", + error_description: "User has not yet authorized", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const request = new Request("http://localhost:20128/api/oauth/grok-cli/poll", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceCode: "opaque-device-code" }), + }); + const response = await route.POST(request, { + params: Promise.resolve({ provider: "grok-cli", action: "poll" }), + }); + const body = await response.json(); + const params = new URLSearchParams(upstreamBody); + + assert.equal(response.status, 200); + assert.equal(body.success, false); + assert.equal(body.pending, true); + assert.equal(body.error, "authorization_pending"); + assert.equal(params.get("device_code"), "opaque-device-code"); +}); diff --git a/tests/unit/grok-cli-oauth.test.ts b/tests/unit/grok-cli-oauth.test.ts index 199deeaa47..3c9b0681b0 100644 --- a/tests/unit/grok-cli-oauth.test.ts +++ b/tests/unit/grok-cli-oauth.test.ts @@ -2,8 +2,13 @@ import test from "node:test"; import assert from "node:assert/strict"; const { grokCli } = await import("../../src/lib/oauth/providers/grok-cli.ts"); +const { GrokCliExecutor } = await import("@omniroute/open-sse/executors/grok-cli"); +const { getGrokBuildClientVersion } = await import("@omniroute/open-sse/config/grokBuild.ts"); const { resolvePublicCred } = await import("@omniroute/open-sse/utils/publicCreds"); +const GROK_CLI_SCOPE = + "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write workspaces:read workspaces:write"; + test("Grok Build OAuth Provider - config", () => { assert.ok(grokCli.config.clientId, "clientId should be defined"); // The public client_id must come from the embedded default (Hard Rule #11), @@ -14,6 +19,7 @@ test("Grok Build OAuth Provider - config", () => { "clientId must resolve from the embedded grok_id default" ); assert.equal(grokCli.config.tokenUrl, "https://auth.x.ai/oauth2/token"); + assert.equal(getGrokBuildClientVersion(), "0.2.106"); }); test("publicCreds: grok_id embedded default is present and decodes", () => { @@ -21,8 +27,181 @@ test("publicCreds: grok_id embedded default is present and decodes", () => { assert.ok(decoded.length > 0, "grok_id must decode to a non-empty client id"); }); -test("Grok Build OAuth Provider - flowType is import_token", () => { - assert.equal(grokCli.flowType, "import_token"); +test("Grok Build OAuth Provider - flowType is device_code", () => { + assert.equal(grokCli.flowType, "device_code"); + assert.equal(grokCli.config.deviceCodeUrl, "https://auth.x.ai/oauth2/device/code"); + assert.equal(grokCli.config.scope, GROK_CLI_SCOPE); +}); + +test("Grok Build OAuth Provider - requests and normalizes a device code", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + let requestUrl = ""; + let requestInit: RequestInit | undefined; + globalThis.fetch = (async (input, init) => { + requestUrl = String(input); + requestInit = init; + return new Response( + JSON.stringify({ + device_code: "opaque-device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://accounts.x.ai/oauth2/device", + verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH", + expires_in: 1800, + interval: 5, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const result = await grokCli.requestDeviceCode(grokCli.config); + const body = new URLSearchParams(String(requestInit?.body)); + + assert.equal(requestUrl, grokCli.config.deviceCodeUrl); + assert.equal(requestInit?.method, "POST"); + assert.equal(body.get("client_id"), grokCli.config.clientId); + assert.equal(body.get("scope"), GROK_CLI_SCOPE); + assert.equal(body.get("referrer"), "grok-build"); + const headers = new Headers(requestInit?.headers); + assert.equal(headers.get("x-grok-client-version"), getGrokBuildClientVersion()); + assert.equal(headers.get("x-grok-client-surface"), "ui"); + assert.equal(result.device_code, "opaque-device-code"); + assert.equal(result.user_code, "ABCD-EFGH"); + assert.equal(result.expires_in, 1800); + assert.equal(result.interval, 5); +}); + +test("Grok Build OAuth Provider - rejects unsafe device authorization responses", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + device_code: "opaque-device-code", + user_code: "ABCD\nEFGH", + verification_uri: "javascript:alert(1)", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + + await assert.rejects(() => grokCli.requestDeviceCode(grokCli.config), /invalid device code/); +}); + +test("Grok Build OAuth Provider - rejects unsupported verification URL schemes", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + device_code: "opaque-device-code", + user_code: "ABCD-EFGH", + verification_uri: "javascript:alert(1)", + }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + + await assert.rejects( + () => grokCli.requestDeviceCode(grokCli.config), + /unsupported verification URL/ + ); +}); + +test("Grok Build OAuth Provider - polls with the standard device grant", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + let requestInit: RequestInit | undefined; + globalThis.fetch = (async (_input, init) => { + requestInit = init; + return new Response( + JSON.stringify({ + error: "authorization_pending", + error_description: "User has not yet authorized", + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const result = await grokCli.pollToken(grokCli.config, "opaque-device-code"); + const body = new URLSearchParams(String(requestInit?.body)); + + assert.equal(body.get("client_id"), grokCli.config.clientId); + assert.equal(body.get("device_code"), "opaque-device-code"); + assert.equal(body.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code"); + const headers = new Headers(requestInit?.headers); + assert.equal(headers.get("x-grok-client-version"), getGrokBuildClientVersion()); + assert.equal(headers.get("x-grok-client-surface"), "ui"); + assert.equal(result.ok, false); + assert.equal(result.data.error, "authorization_pending"); +}); + +test("Grok Build OAuth Provider - maps a standard OAuth token response", () => { + const accessPayload = { + sub: "user-123", + email: "device@example.com", + team_id: "team-456", + tier: 2, + principal_type: "Team", + principal_id: "team-456", + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const accessToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(accessPayload)).toString("base64url")}.signature`; + const idToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from( + JSON.stringify({ email: "device@example.com" }) + ).toString("base64url")}.signature`; + + const result = grokCli.mapTokens({ + access_token: accessToken, + refresh_token: "refresh-device-token", + id_token: idToken, + expires_in: 3600, + token_type: "Bearer", + scope: GROK_CLI_SCOPE, + }); + + assert.equal(result.accessToken, accessToken); + assert.equal(result.refreshToken, "refresh-device-token"); + assert.equal(result.idToken, idToken); + assert.equal(result.expiresIn, 3600); + assert.equal(result.tokenType, "Bearer"); + assert.equal(result.scope, GROK_CLI_SCOPE); + assert.equal(result.email, "device@example.com"); + assert.equal(result.providerSpecificData?.teamId, "team-456"); + assert.equal(result.providerSpecificData?.userId, "team-456"); + assert.equal(result.providerSpecificData?.email, "device@example.com"); + assert.equal(result.providerSpecificData?.principalType, "Team"); + assert.equal(result.providerSpecificData?.principalId, "team-456"); +}); + +test("Grok Build OAuth Provider - maps organization principals to their principal id", () => { + const accessPayload = { + sub: "user-123", + principal_type: "Organization", + principal_id: "org-456", + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const idPayload = { sub: "user-123", email: "org-user@example.com" }; + const accessToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(accessPayload)).toString("base64url")}.signature`; + const idToken = `eyJhbGciOiJFUzI1NiJ9.${Buffer.from(JSON.stringify(idPayload)).toString("base64url")}.signature`; + + const result = grokCli.mapTokens({ access_token: accessToken, id_token: idToken }); + + assert.equal(result.email, "org-user@example.com"); + assert.equal(result.providerSpecificData?.userId, "org-456"); + assert.equal(result.providerSpecificData?.organizationId, "org-456"); + assert.equal(result.providerSpecificData?.principalType, "Organization"); + assert.equal(result.providerSpecificData?.principalId, "org-456"); }); test("Grok Build OAuth Provider - mapTokens from raw JWT", () => { @@ -107,6 +286,30 @@ test("Grok Build OAuth Provider - mapTokens from direct auth.json has rawAuthJso assert.deepEqual(result.providerSpecificData?.rawAuthJson, authJson); }); +test("Grok Build OAuth Provider - prefers the active issuer/client auth.json scope", () => { + const otherPayload = Buffer.from(JSON.stringify({ email: "other@example.com" })).toString( + "base64url" + ); + const preferredPayload = Buffer.from(JSON.stringify({ email: "preferred@example.com" })).toString( + "base64url" + ); + const authJson = { + "https://auth.x.ai::other-client": { + key: `eyJhbGciOiJFUzI1NiJ9.${otherPayload}.signature`, + refresh_token: "other-refresh", + }, + [`https://auth.x.ai::${grokCli.config.clientId}`]: { + key: `eyJhbGciOiJFUzI1NiJ9.${preferredPayload}.signature`, + refresh_token: "preferred-refresh", + }, + }; + + const result = grokCli.mapTokens(authJson, null); + + assert.equal(result.email, "preferred@example.com"); + assert.equal(result.refreshToken, "preferred-refresh"); +}); + test("Grok Build OAuth Provider - mapTokens from raw JWT has no rawAuthJson", () => { const payload = { sub: "12345", email: "test@example.com" }; const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url"); @@ -181,3 +384,92 @@ test("Grok Build OAuth Provider - mapTokens clamps expired JSON expires_at to a assert.ok(result.expiresIn >= 1, `expected expiresIn >= 1, got ${result.expiresIn}`); }); + +test("Grok Build executor refresh forwards principal metadata and preserves token rotation", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + let requestUrl = ""; + let requestInit: RequestInit | undefined; + globalThis.fetch = (async (input, init) => { + requestUrl = String(input); + requestInit = init; + return new Response( + JSON.stringify({ + access_token: "new-access-token", + refresh_token: "rotated-refresh-token", + expires_in: 900, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; + + const result = await new GrokCliExecutor().refreshCredentials({ + refreshToken: "old-refresh-token", + providerSpecificData: { principalType: "Team", principalId: "team-123" }, + }); + const body = new URLSearchParams(String(requestInit?.body)); + + assert.equal(requestUrl, "https://auth.x.ai/oauth2/token"); + assert.equal(body.get("grant_type"), "refresh_token"); + assert.equal(body.get("refresh_token"), "old-refresh-token"); + assert.equal(body.get("principal_type"), "Team"); + assert.equal(body.get("principal_id"), "team-123"); + assert.equal(result?.accessToken, "new-access-token"); + assert.equal(result?.refreshToken, "rotated-refresh-token"); +}); + +test("Grok Build executor retries transient refresh failures", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + let calls = 0; + globalThis.fetch = (async () => { + calls++; + if (calls === 1) { + return new Response(JSON.stringify({ error: "temporarily_unavailable" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ access_token: "recovered-access-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + const result = await new GrokCliExecutor().refreshCredentials({ + refreshToken: "refresh-token", + }); + + assert.equal(calls, 2); + assert.equal(result?.accessToken, "recovered-access-token"); + assert.equal(result?.refreshToken, "refresh-token"); +}); + +test("Grok Build executor does not retry terminal refresh failures", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + let calls = 0; + globalThis.fetch = (async () => { + calls++; + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + const result = await new GrokCliExecutor().refreshCredentials({ + refreshToken: "revoked-refresh-token", + }); + + assert.equal(calls, 1); + assert.equal(result, null); +}); diff --git a/tests/unit/grok-cli-proactive-refresh-7610.test.ts b/tests/unit/grok-cli-proactive-refresh-7610.test.ts index fd6b4750fa..b9ea776e85 100644 --- a/tests/unit/grok-cli-proactive-refresh-7610.test.ts +++ b/tests/unit/grok-cli-proactive-refresh-7610.test.ts @@ -1,29 +1,18 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts"; -import type { ExecuteInput, ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts"; - -type TestableGrokCliExecutor = { - execute: (input: ExecuteInput) => Promise<{ response: Response }>; - refreshCredentials: ( - credentials: ProviderCredentials, - log?: ExecutorLog | null - ) => Promise | null>; - nativePost: ( - url: string, - headers: Record, - bodyStr: string, - signal?: AbortSignal | null - ) => Promise; -}; +import type { ExecuteInput, ProviderCredentials } from "../../open-sse/executors/base.ts"; test("GrokCliExecutor.execute() proactively refreshes an expired access token (#7610)", async () => { - const executor = new GrokCliExecutor() as unknown as TestableGrokCliExecutor; + const executor = new GrokCliExecutor(); - // Stub the real network call (nativeHttpsPost → auth.x.ai) so the test never - // touches the network — only the wiring (does execute() call - // refreshCredentials() at all, and does the refreshed token reach the - // outgoing Authorization header) is under test here. + // #7358 moved grok-cli off the raw https.request()/nativePost dispatch onto the + // shared fetch-based BaseExecutor.execute() path (buildUrl/buildHeaders only) — + // which already runs the same proactive-refresh gate generically (base.ts:600). + // Stub refreshCredentials() (the network call to auth.x.ai) and global fetch (the + // upstream Grok Build call) so only the wiring is under test: does execute() call + // refreshCredentials() before dispatch, and does the refreshed token reach the + // outgoing Authorization header. let refreshCalled = false; executor.refreshCredentials = async () => { refreshCalled = true; @@ -35,30 +24,37 @@ test("GrokCliExecutor.execute() proactively refreshes an expired access token (# }; let capturedHeaders: Record | null = null; - executor.nativePost = async (_url, headers) => { - capturedHeaders = headers; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string, init: RequestInit = {}) => { + capturedHeaders = Object.fromEntries( + new Headers(init.headers as HeadersInit).entries() + ) as Record; return new Response(JSON.stringify({ ok: true }), { status: 200 }); - }; + }) as typeof fetch; - const expiredAt = new Date(Date.now() - 60_000).toISOString(); - const credentials: ProviderCredentials = { - accessToken: "STALE_ACCESS_TOKEN", - refreshToken: "valid-refresh-token", - expiresAt: expiredAt, - }; + try { + const expiredAt = new Date(Date.now() - 60_000).toISOString(); + const credentials: ProviderCredentials = { + accessToken: "STALE_ACCESS_TOKEN", + refreshToken: "valid-refresh-token", + expiresAt: expiredAt, + }; - await executor.execute({ - model: "grok-composer-2.5-fast", - body: { messages: [{ role: "user", content: "hi" }] }, - stream: false, - credentials, - } as ExecuteInput); + await executor.execute({ + model: "grok-composer-2.5-fast", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials, + } as ExecuteInput); - assert.equal( - refreshCalled, - true, - "expected GrokCliExecutor.execute() to proactively call refreshCredentials()" - ); - assert.notEqual(capturedHeaders?.["Authorization"], "Bearer STALE_ACCESS_TOKEN"); - assert.equal(capturedHeaders?.["Authorization"], "Bearer FRESH_ACCESS_TOKEN"); + assert.equal( + refreshCalled, + true, + "expected GrokCliExecutor.execute() to proactively call refreshCredentials()" + ); + assert.notEqual(capturedHeaders?.["authorization"], "Bearer STALE_ACCESS_TOKEN"); + assert.equal(capturedHeaders?.["authorization"], "Bearer FRESH_ACCESS_TOKEN"); + } finally { + globalThis.fetch = originalFetch; + } }); diff --git a/tests/unit/grok-cli-proxy-selection.test.ts b/tests/unit/grok-cli-proxy-selection.test.ts index 764ed364c2..9db49ee753 100644 --- a/tests/unit/grok-cli-proxy-selection.test.ts +++ b/tests/unit/grok-cli-proxy-selection.test.ts @@ -1,56 +1,15 @@ -// Regression test for: Grok Build (grok-cli) inference/refresh requests bypassed the -// operator's configured proxy entirely. The executor talks to Grok's upstream via raw -// Node `https.request()` (forced IPv4, to dodge Cloudflare blocking on the direct path) -// instead of the process-wide patched `fetch()` that every other executor uses — so a -// proxy pinned to the connection/provider/global scope was silently ignored, leaking the -// real egress IP and defeating account-isolation/anonymity setups. Mirrors the class of -// bug fixed upstream in decolua/9router#2343 ("fix(oauth): honor proxy selection during -// OAuth login"), adapted to OmniRoute's actual grok-cli architecture (import-token flow, -// no device-code polling) where the leak lives in `resolveGrokRequestDispatch()`. -import { test } from "node:test"; +// Regression test for #7244: Grok Build inference used to bypass the configured +// proxy because the legacy executor overrode execute() with raw https.request(). +// The official-client implementation must stay on BaseExecutor's shared fetch +// transport, which is patched by proxyFetch and receives the active proxy context. +import test from "node:test"; import assert from "node:assert/strict"; -import { resolveGrokRequestDispatch } from "../../open-sse/executors/grok-cli.ts"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts"; -const TARGET_URL = "https://grok.x.ai/rest/app-chat/conversations/new"; +test("grok-cli inherits the shared proxy-aware BaseExecutor transport", () => { + const executor = new GrokCliExecutor(); -test("resolveGrokRequestDispatch: no proxy configured -> direct IPv4 dispatch (unchanged behavior)", () => { - const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({ - source: "direct", - proxyUrl: null, - })); - - assert.equal(dispatch.family, 4); - assert.equal(dispatch.agent, undefined); -}); - -test("resolveGrokRequestDispatch: HTTP proxy configured -> request is dispatched through a proxy agent, not direct", () => { - const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({ - source: "context", - proxyUrl: "http://proxy.internal:8080", - })); - - // The fix: an agent bound to the configured proxy must be present, and the - // direct-IPv4 workaround must NOT be applied (it would race the proxy tunnel). - assert.ok(dispatch.agent, "expected a proxy agent to be constructed"); - assert.notEqual(dispatch.family, 4); -}); - -test("resolveGrokRequestDispatch: HTTPS proxy configured -> request is dispatched through a proxy agent", () => { - const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({ - source: "context", - proxyUrl: "https://user:pass@proxy.internal:8443", - })); - - assert.ok(dispatch.agent, "expected a proxy agent to be constructed"); -}); - -test("resolveGrokRequestDispatch: unsupported proxy protocol (socks5) fails closed instead of leaking direct", () => { - assert.throws( - () => - resolveGrokRequestDispatch(TARGET_URL, () => ({ - source: "context", - proxyUrl: "socks5://proxy.internal:1080", - })), - /proxy/i - ); + assert.equal(Object.hasOwn(GrokCliExecutor.prototype, "execute"), false); + assert.equal(executor.execute, BaseExecutor.prototype.execute); }); diff --git a/tests/unit/grok-cli-reasoning-strip-6288.test.ts b/tests/unit/grok-cli-reasoning-strip-6288.test.ts index d84bb2f438..f9e066cbab 100644 --- a/tests/unit/grok-cli-reasoning-strip-6288.test.ts +++ b/tests/unit/grok-cli-reasoning-strip-6288.test.ts @@ -3,70 +3,113 @@ import assert from "node:assert/strict"; const { GrokCliExecutor } = await import("@omniroute/open-sse/executors/grok-cli"); -// Regression for #6288: Grok Build (`grok-cli` executor) returns 400 on every -// request from Claude Code because Claude Code forwards `reasoning_effort` -// (and sometimes a nested `reasoning` object), which Grok Build's upstream -// chat-proxy endpoint does not accept. transformRequest() must strip both -// before forwarding, without breaking the existing #5273 stripping. +// #6288 originally protected the legacy Chat Completions bridge from reasoning +// fields. Both current models now use Responses; grok-4.5 accepts an effort, +// while Composer does not. -test("#6288 grok-cli transformRequest strips reasoning_effort", () => { +test("grok-4.5 preserves Responses reasoning", () => { const executor = new GrokCliExecutor(); const body = { - model: "grok-build", - messages: [{ role: "user", content: "hi" }], - reasoning_effort: "high", + model: "grok-4.5", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + reasoning: { effort: "high", summary: "auto" }, }; - const out = executor.transformRequest("grok-build", body, false, {} as never) as Record< + const out = executor.transformRequest("grok-4.5", body, true, {} as never) as Record< string, unknown >; - assert.equal("reasoning_effort" in out, false, "reasoning_effort must be stripped"); - assert.deepEqual(out.messages, [{ role: "user", content: "hi" }]); - assert.equal(out.model, "grok-build"); + assert.deepEqual(out.reasoning, { effort: "high", summary: "auto" }); }); -test("#6288 grok-cli transformRequest strips nested reasoning object", () => { +test("grok composer strips unsupported Responses reasoning", () => { const executor = new GrokCliExecutor(); const body = { - model: "grok-build", - messages: [{ role: "user", content: "hi" }], + model: "grok-composer-2.5-fast", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], reasoning: { effort: "high" }, }; - const out = executor.transformRequest("grok-build", body, false, {} as never) as Record< - string, - unknown - >; + const out = executor.transformRequest( + "grok-composer-2.5-fast", + body, + false, + {} as never + ) as Record; - assert.equal("reasoning" in out, false, "reasoning must be stripped"); + assert.equal("reasoning" in out, false); }); -test("#6288 grok-cli transformRequest still strips #5273 unsupported sampling params", () => { +test("grok-cli strips legacy top-level reasoning_effort after translation", () => { const executor = new GrokCliExecutor(); const body = { - model: "grok-build", - messages: [{ role: "user", content: "hi" }], - presencePenalty: 0.5, - frequencyPenalty: 0.3, - logprobs: true, - topLogprobs: 5, - reasoning_effort: "medium", + model: "grok-4.5", + input: [], + reasoning_effort: "high", }; - const out = executor.transformRequest("grok-build", body, false, {} as never) as Record< + const out = executor.transformRequest("grok-4.5", body, false, {} as never) as Record< string, unknown >; - for (const param of [ - "presencePenalty", - "frequencyPenalty", - "logprobs", - "topLogprobs", - "reasoning_effort", - ]) { - assert.equal(param in out, false, `${param} must be stripped`); - } + assert.equal("reasoning_effort" in out, false); +}); + +test("grok-cli applies official Responses defaults without mutating client input", () => { + const executor = new GrokCliExecutor(); + const body = { + model: "grok-4.5", + input: [], + include: ["file_search_call.results"], + }; + + const out = executor.transformRequest("grok-4.5", body, true, {} as never) as Record< + string, + unknown + >; + + assert.equal(out.store, false); + assert.deepEqual(out.include, ["file_search_call.results", "reasoning.encrypted_content"]); + assert.deepEqual(out.reasoning, { effort: "high" }); + assert.deepEqual(body, { + model: "grok-4.5", + input: [], + include: ["file_search_call.results"], + }); +}); + +test("grok-cli preserves explicit store and de-duplicates encrypted reasoning include", () => { + const executor = new GrokCliExecutor(); + const out = executor.transformRequest( + "grok-4.5", + { + input: [], + store: true, + include: ["reasoning.encrypted_content"], + reasoning: { effort: "xhigh" }, + }, + false, + {} as never + ) as Record; + + assert.equal(out.store, true); + assert.deepEqual(out.include, ["reasoning.encrypted_content"]); + assert.equal("reasoning" in out, false); +}); + +test("grok-cli preserves an explicit Responses reasoning summary", () => { + const executor = new GrokCliExecutor(); + const out = executor.transformRequest( + "grok-4.5", + { + input: [], + reasoning: { summary: "concise" }, + }, + false, + {} as never + ) as Record; + + assert.deepEqual(out.reasoning, { summary: "concise", effort: "high" }); }); diff --git a/tests/unit/grok-cli-responses-compat.test.ts b/tests/unit/grok-cli-responses-compat.test.ts new file mode 100644 index 0000000000..ad9888fe94 --- /dev/null +++ b/tests/unit/grok-cli-responses-compat.test.ts @@ -0,0 +1,184 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { grok_cliProvider } from "../../open-sse/config/providers/registry/grok-cli/index.ts"; +import { + GROK_BUILD_DEFAULT_CONTEXT_WINDOW, + getGrokBuildClientVersion, + getGrokBuildUserAgent, + GROK_BUILD_MODELS_URL, +} from "../../open-sse/config/grokBuild.ts"; +import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; +import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts"; + +test("grok-cli exposes the authenticated grok-build model catalog", () => { + assert.deepEqual( + grok_cliProvider.models.map(({ id, name, contextLength, targetFormat }) => ({ + id, + name, + contextLength, + targetFormat, + })), + [ + { + id: "grok-4.5", + name: "Grok 4.5", + contextLength: 500000, + targetFormat: "openai-responses", + }, + { + id: "grok-composer-2.5-fast", + name: "Composer 2.5", + contextLength: 200000, + targetFormat: "openai-responses", + }, + ] + ); + assert.equal(getModelTargetFormat("gc", "grok-4.5"), "openai-responses"); + assert.equal(getModelTargetFormat("gc", "grok-composer-2.5-fast"), "openai-responses"); + assert.equal(grok_cliProvider.modelsUrl, GROK_BUILD_MODELS_URL); +}); + +test("grok-cli routes both models to the Responses endpoint", () => { + const executor = new GrokCliExecutor(); + assert.equal(executor.buildUrl("grok-4.5", true), "https://cli-chat-proxy.grok.com/v1/responses"); + assert.equal( + executor.buildUrl("grok-composer-2.5-fast", false), + "https://cli-chat-proxy.grok.com/v1/responses" + ); +}); + +test("grok-cli sends the current grok-build session headers", () => { + const executor = new GrokCliExecutor(); + const streaming = executor.buildHeaders( + { + accessToken: "token", + providerSpecificData: { userId: "user-123", email: "grok@example.com" }, + }, + true, + null, + "grok-4.5" + ); + assert.equal(streaming.Authorization, "Bearer token"); + assert.equal(streaming.Accept, "text/event-stream"); + assert.equal(streaming["x-grok-client-version"], getGrokBuildClientVersion()); + assert.equal(streaming["x-grok-client-identifier"], "grok-shell"); + assert.equal(streaming["x-grok-client-mode"], "headless"); + assert.equal(streaming["User-Agent"], getGrokBuildUserAgent()); + assert.equal(streaming["X-XAI-Token-Auth"], "xai-grok-cli"); + assert.equal(streaming["x-authenticateresponse"], "authenticate-response"); + assert.equal(streaming["x-grok-model-override"], "grok-4.5"); + assert.equal(streaming["x-userid"], "user-123"); + assert.equal(streaming["x-grok-user-id"], "user-123"); + assert.equal(streaming["x-email"], "grok@example.com"); + + const team = executor.buildHeaders( + { + accessToken: "token", + email: "member@example.com", + providerSpecificData: { + userId: "team-123", + principalType: "Team", + }, + }, + true, + null, + "grok-4.5" + ); + assert.equal(team["x-userid"], "team-123"); + assert.equal("x-email" in team, false); + + const json = executor.buildHeaders({ apiKey: "token" }, false, null, "grok-composer-2.5-fast"); + assert.equal(json.Authorization, "Bearer token"); + assert.equal(json.Accept, "application/json"); + assert.equal(json["x-grok-model-override"], "grok-composer-2.5-fast"); +}); + +test("grok-cli renders the official Windows platform name in its user agent", () => { + if (process.platform !== "win32") return; + assert.match(getGrokBuildUserAgent(), /\(windows; /); +}); + +test("grok-cli inherits BaseExecutor transport instead of buffering its own response", () => { + assert.equal(Object.hasOwn(GrokCliExecutor.prototype, "execute"), false); + assert.equal(new GrokCliExecutor().execute, BaseExecutor.prototype.execute); +}); + +test("grok-cli live model discovery uses the authenticated session contract", () => { + const config = PROVIDER_MODELS_CONFIG["grok-cli"]; + assert.equal(config.url, GROK_BUILD_MODELS_URL); + + const headers = config.buildHeaders?.("token", { + providerSpecificData: { userId: "user-123" }, + email: "grok@example.com", + }); + assert.equal(headers?.Authorization, "Bearer token"); + assert.equal(headers?.["X-XAI-Token-Auth"], "xai-grok-cli"); + assert.equal(headers?.["x-userid"], "user-123"); + assert.equal(headers?.["x-email"], "grok@example.com"); + assert.equal(headers?.["x-grok-client-version"], getGrokBuildClientVersion()); + + const teamHeaders = config.buildHeaders?.("token", { + providerSpecificData: { + userId: "team-123", + email: "member@example.com", + principalType: "Team", + }, + email: "member@example.com", + }); + assert.equal(teamHeaders?.["x-userid"], "team-123"); + assert.equal("x-email" in (teamHeaders || {}), false); + + const models = config.parseResponse({ + data: [ + { + id: "catalog-alias", + model: "grok-4.5", + name: "Grok 4.5", + contextWindow: 500000, + apiBackend: "responses", + supportsReasoningEffort: true, + }, + { + id: "hidden-experiment", + model: "hidden-experiment", + context_window: 1000, + hidden: true, + }, + { + id: "legacy-chat-model", + model: "legacy-chat-model", + apiBackend: "chat_completions", + }, + { + id: "session-only-alias", + name: "Session Only", + apiBackend: "responses", + supportedInApi: false, + _meta: { model: "metadata-model-must-not-override-id" }, + }, + ], + }); + + assert.deepEqual(models, [ + { + id: "grok-4.5", + name: "Grok 4.5", + owned_by: "grok-cli", + inputTokenLimit: 500000, + supportsThinking: true, + apiFormat: "responses", + supportedEndpoints: ["responses"], + }, + { + id: "session-only-alias", + name: "Session Only", + owned_by: "grok-cli", + inputTokenLimit: GROK_BUILD_DEFAULT_CONTEXT_WINDOW, + apiFormat: "responses", + supportedEndpoints: ["responses"], + }, + ]); +}); diff --git a/tests/unit/grok-cli-strip-params.test.ts b/tests/unit/grok-cli-strip-params.test.ts index 46c97598f5..f3869cac0e 100644 --- a/tests/unit/grok-cli-strip-params.test.ts +++ b/tests/unit/grok-cli-strip-params.test.ts @@ -6,22 +6,33 @@ const { GrokCliExecutor } = await import("@omniroute/open-sse/executors/grok-cli // Regression for #5273: Grok Build returns `400 'Model does not support parameter // presencePenalty'` when clients (MiMoCode, Cursor, …) send OpenAI-style sampling // params Grok Build cannot accept. transformRequest() must strip them before forwarding. -const UNSUPPORTED = ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"]; +const UNSUPPORTED = [ + "presencePenalty", + "frequencyPenalty", + "logprobs", + "topLogprobs", + "presence_penalty", + "frequency_penalty", + "top_logprobs", +]; test("#5273 grok-cli transformRequest strips unsupported sampling params", () => { const executor = new GrokCliExecutor(); const body = { - model: "grok-build", - messages: [{ role: "user", content: "hi" }], + model: "grok-4.5", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], temperature: 0.7, top_p: 0.9, presencePenalty: 0.5, frequencyPenalty: 0.3, logprobs: true, topLogprobs: 5, + presence_penalty: 0.4, + frequency_penalty: 0.2, + top_logprobs: 3, }; - const out = executor.transformRequest("grok-build", body, false, {} as never) as Record< + const out = executor.transformRequest("grok-4.5", body, false, {} as never) as Record< string, unknown >; @@ -33,8 +44,8 @@ test("#5273 grok-cli transformRequest strips unsupported sampling params", () => // …while supported params + payload survive untouched. assert.equal(out.temperature, 0.7); assert.equal(out.top_p, 0.9); - assert.deepEqual(out.messages, [{ role: "user", content: "hi" }]); - assert.equal(out.model, "grok-build"); + assert.deepEqual(out.input, [{ role: "user", content: [{ type: "input_text", text: "hi" }] }]); + assert.equal(out.model, "grok-4.5"); assert.equal(out.stream, false); }); diff --git a/tests/unit/ui/grok-device-oauth-modal.test.tsx b/tests/unit/ui/grok-device-oauth-modal.test.tsx new file mode 100644 index 0000000000..5b41fa24c3 --- /dev/null +++ b/tests/unit/ui/grok-device-oauth-modal.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: OAuthModal, formatDeviceCodeRemaining } = + await import("@/shared/components/OAuthModal"); + +const roots: Array<{ root: ReturnType; element: HTMLDivElement }> = []; + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function renderModal(isOpen: boolean, reauthConnection?: { id: string }) { + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + roots.push({ root, element }); + act(() => { + root.render( + + ); + }); + return { root, element }; +} + +describe("OAuthModal Grok Device Code", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-13T00:00:00Z")); + }); + + afterEach(() => { + for (const { root, element } of roots.splice(0)) { + act(() => root.unmount()); + element.remove(); + } + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("starts Browser Login, opens xAI, and renders code plus countdown", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { + if (String(input).includes("/device-code")) { + return new Response( + JSON.stringify({ + device_code: "opaque-device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://accounts.x.ai/oauth2/device", + verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH", + expires_in: 1800, + interval: 5, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify({ success: false, pending: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + const openMock = vi.spyOn(window, "open").mockImplementation(() => null); + + const { element } = renderModal(true, { id: "conn-existing" }); + await flushEffects(); + + expect(fetchMock.mock.calls[0]?.[0].toString()).toContain("/api/oauth/grok-cli/device-code"); + expect(openMock).toHaveBeenCalledWith( + "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH", + "oauth_verify" + ); + expect(element.textContent).toContain("ABCD-EFGH"); + expect(element.textContent).toContain("30:00"); + expect(element.textContent).toContain("Browser Login"); + expect(element.textContent).toContain("JWT Token"); + expect( + element.querySelector('a[href="https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH"]') + ).toBeTruthy(); + expect(formatDeviceCodeRemaining(65)).toBe("1:05"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + const pollCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/poll")); + expect(pollCall).toBeTruthy(); + const pollBody = JSON.parse(String((pollCall?.[1] as RequestInit).body)); + expect(pollBody.connectionId).toBe("conn-existing"); + }); + + it("cancels the old poll when the modal closes", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { + if (String(input).includes("/device-code")) { + return new Response( + JSON.stringify({ + device_code: "opaque-device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://accounts.x.ai/oauth2/device", + verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH", + expires_in: 1800, + interval: 5, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify({ success: false, pending: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(window, "open").mockImplementation(() => null); + + const { root, element } = renderModal(true); + await flushEffects(); + act(() => { + root.render( + + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + + const pollCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/poll")); + expect(pollCalls).toHaveLength(0); + expect(element.textContent).toBe(""); + }); +});