mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
feat: Add Grok Build (xAI) provider with OAuth import-token flow (#5020)
Integrated into release/v3.8.38 (rebased on tip; Hard Rule #11 fix — Grok public client_id now via resolvePublicCred(grok_id), 3 literals removed; grok-oauth 7/7 + check:public-creds green)
This commit is contained in:
@@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas)
|
||||
- **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic)
|
||||
- **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil)
|
||||
- **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij)
|
||||
|
||||
@@ -167,6 +167,7 @@ import { kiroProvider } from "./registry/kiro/index.ts";
|
||||
import { openadapterProvider } from "./registry/openadapter/index.ts";
|
||||
import { ditProvider } from "./registry/dit/index.ts";
|
||||
import { tokenrouterProvider } from "./registry/tokenrouter/index.ts";
|
||||
import { grok_cliProvider } from "./registry/grok-cli/index.ts";
|
||||
import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts";
|
||||
import { pioneerProvider } from "./registry/pioneer/index.ts";
|
||||
import { zenmux_freeProvider } from "./registry/zenmux-free/index.ts";
|
||||
@@ -339,6 +340,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
openadapter: openadapterProvider,
|
||||
dit: ditProvider,
|
||||
tokenrouter: tokenrouterProvider,
|
||||
"grok-cli": grok_cliProvider,
|
||||
"codebuddy-cn": codebuddy_cnProvider,
|
||||
pioneer: pioneerProvider,
|
||||
"zenmux-free": zenmux_freeProvider,
|
||||
|
||||
22
open-sse/config/providers/registry/grok-cli/index.ts
Normal file
22
open-sse/config/providers/registry/grok-cli/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { resolvePublicCred } from "../../shared.ts";
|
||||
|
||||
export const grok_cliProvider: RegistryEntry = {
|
||||
id: "grok-cli",
|
||||
alias: "gc",
|
||||
format: "openai",
|
||||
executor: "grok-cli",
|
||||
baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions",
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
passthroughModels: true,
|
||||
models: [
|
||||
{ id: "grok-build", name: "Grok Build", contextLength: 128000 },
|
||||
{ id: "grok-composer-2.5-fast", name: "Grok Composer 2.5 Fast", contextLength: 128000 },
|
||||
],
|
||||
oauth: {
|
||||
clientIdEnv: "GROK_OAUTH_CLIENT_ID",
|
||||
clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
|
||||
tokenUrl: "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
};
|
||||
244
open-sse/executors/grok-cli.ts
Normal file
244
open-sse/executors/grok-cli.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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.
|
||||
* Supports automatic token refresh via refresh_token.
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseExecutor,
|
||||
type ExecuteInput,
|
||||
type ExecutorLog,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { resolvePublicCred } from "../utils/publicCreds.ts";
|
||||
import https from "node:https";
|
||||
|
||||
const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token";
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
export class GrokCliExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("grok-cli", PROVIDERS["grok-cli"]);
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { model, body, stream, credentials, signal } = input;
|
||||
|
||||
const url = this.buildUrl(model, stream, 0, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
const bodyStr = JSON.stringify(transformedBody);
|
||||
|
||||
const response = await this.nativePost(url, headers, bodyStr, signal);
|
||||
return { response, url, headers, transformedBody };
|
||||
}
|
||||
|
||||
async refreshCredentials(
|
||||
credentials: ProviderCredentials,
|
||||
log?: ExecutorLog | null
|
||||
): Promise<Partial<ProviderCredentials> | null> {
|
||||
if (!credentials?.refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", "Grok Build: no refresh token available");
|
||||
return null;
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
bodyStr: string,
|
||||
timeoutMs = 10_000
|
||||
): Promise<{ status: number; body: string }> {
|
||||
const urlObj = new URL(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",
|
||||
family: 4,
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
private nativePost(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
bodyStr: string,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<Response> {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
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",
|
||||
family: 4,
|
||||
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<string, string> = {};
|
||||
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));
|
||||
}
|
||||
|
||||
req.on("error", (err) => settle(() => reject(err)));
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ProviderCredentials, stream = true) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
} else if (credentials.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
|
||||
}
|
||||
|
||||
headers["Accept"] = stream ? "text/event-stream" : "application/json";
|
||||
headers["x-grok-client-version"] = "0.2.64";
|
||||
headers["x-grok-client-identifier"] = "grok_cli_rs";
|
||||
headers["User-Agent"] = "grok-cli/0.2.64 (Windows 10.0.26200; x64)";
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(
|
||||
model: string,
|
||||
body: unknown,
|
||||
stream: boolean,
|
||||
_credentials: ProviderCredentials
|
||||
) {
|
||||
const transformed =
|
||||
body && typeof body === "object" ? { ...(body as Record<string, unknown>) } : {};
|
||||
if (!transformed.model) {
|
||||
transformed.model = model || "grok-composer-2.5-fast";
|
||||
}
|
||||
transformed.stream = !!stream;
|
||||
return transformed;
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,16 @@ import { V0VercelWebExecutor } from "./v0-vercel-web.ts";
|
||||
import { KimiWebExecutor } from "./kimi-web.ts";
|
||||
import { DoubaoWebExecutor } from "./doubao-web.ts";
|
||||
import { QwenWebExecutor } from "./qwen-web.ts";
|
||||
import { KimiExecutor } from "./kimi.ts"
|
||||
import { KimiExecutor } from "./kimi.ts";
|
||||
import { TheOldLlmExecutor } from "./theoldllm.ts";
|
||||
import { ChipotleExecutor } from "./chipotle.ts";
|
||||
import { LMArenaExecutor } from "./lmarena.ts";
|
||||
import { MimocodeExecutor } from "./mimocode.ts";
|
||||
import { GrokCliExecutor } from "./grok-cli.ts";
|
||||
import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
|
||||
import { ZenmuxFreeExecutor } from "./zenmux-free.ts";
|
||||
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
agy: new AntigravityExecutor(),
|
||||
@@ -150,6 +152,8 @@ const executors = {
|
||||
lma: new LMArenaExecutor(), // Alias
|
||||
mimocode: new MimocodeExecutor(),
|
||||
mcode: new MimocodeExecutor(), // Alias
|
||||
"grok-cli": new GrokCliExecutor(),
|
||||
gc: new GrokCliExecutor(), // Alias
|
||||
"codebuddy-cn": new CodeBuddyCnExecutor(),
|
||||
cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn
|
||||
"zenmux-free": new ZenmuxFreeExecutor(),
|
||||
@@ -214,5 +218,6 @@ export { TheOldLlmExecutor } from "./theoldllm.ts";
|
||||
export { ChipotleExecutor } from "./chipotle.ts";
|
||||
export { LMArenaExecutor } from "./lmarena.ts";
|
||||
export { MimocodeExecutor } from "./mimocode.ts";
|
||||
export { GrokCliExecutor } from "./grok-cli.ts";
|
||||
export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
|
||||
export { ZenmuxFreeExecutor } from "./zenmux-free.ts";
|
||||
|
||||
@@ -179,6 +179,11 @@ const EMBEDDED_DEFAULTS = {
|
||||
github_copilot_id: [
|
||||
38, 27, 95, 71, 16, 90, 69, 67, 4, 29, 72, 22, 90, 91, 12, 0, 75, 19, 8, 87,
|
||||
],
|
||||
// Grok Build CLI (xAI) — public oauth client id (import-token flow)
|
||||
grok_id: [
|
||||
13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95,
|
||||
91, 22, 71, 87, 20, 66, 67, 86, 13, 81, 81, 21,
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS;
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function ProviderDetailPageClient() {
|
||||
const [codexCliGuideOpen, setCodexCliGuideOpen] = useState(false);
|
||||
const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false);
|
||||
const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false);
|
||||
const [importGrokCliModalOpen, setImportGrokCliModalOpen] = useState(false);
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||
const isCommandCode = providerId === "command-code";
|
||||
@@ -520,6 +521,7 @@ export default function ProviderDetailPageClient() {
|
||||
onOpenImportCodex={() => setImportCodexModalOpen(true)}
|
||||
onOpenImportClaude={() => setImportClaudeModalOpen(true)}
|
||||
onOpenImportGemini={() => setImportGeminiModalOpen(true)}
|
||||
onOpenImportGrokCli={() => setImportGrokCliModalOpen(true)}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -539,6 +541,7 @@ export default function ProviderDetailPageClient() {
|
||||
onOpenImportCodex={() => setImportCodexModalOpen(true)}
|
||||
onOpenImportClaude={() => setImportClaudeModalOpen(true)}
|
||||
onOpenImportGemini={() => setImportGeminiModalOpen(true)}
|
||||
onOpenImportGrokCli={() => setImportGrokCliModalOpen(true)}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
@@ -759,6 +762,8 @@ export default function ProviderDetailPageClient() {
|
||||
handleApplyGeminiAuthLocal={handleApplyGeminiAuthLocal}
|
||||
importGeminiModalOpen={importGeminiModalOpen}
|
||||
setImportGeminiModalOpen={setImportGeminiModalOpen}
|
||||
importGrokCliModalOpen={importGrokCliModalOpen}
|
||||
setImportGrokCliModalOpen={setImportGrokCliModalOpen}
|
||||
batchTestResults={batchTestResults}
|
||||
setBatchTestResults={setBatchTestResults}
|
||||
emailsVisible={emailsVisible}
|
||||
|
||||
@@ -45,6 +45,7 @@ type ConnectionsHeaderToolbarProps = {
|
||||
onOpenImportCodex: () => void;
|
||||
onOpenImportClaude: () => void;
|
||||
onOpenImportGemini: () => void;
|
||||
onOpenImportGrokCli: () => void;
|
||||
t: ProviderMessageTranslator;
|
||||
};
|
||||
|
||||
@@ -87,6 +88,7 @@ export default function ConnectionsHeaderToolbar({
|
||||
onOpenImportCodex,
|
||||
onOpenImportClaude,
|
||||
onOpenImportGemini,
|
||||
onOpenImportGrokCli,
|
||||
t,
|
||||
}: ConnectionsHeaderToolbarProps) {
|
||||
return (
|
||||
@@ -338,6 +340,16 @@ export default function ConnectionsHeaderToolbar({
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "grok-cli" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportGrokCli)}
|
||||
>
|
||||
Import auth
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EmptyConnectionsPlaceholderProps {
|
||||
onOpenImportCodex: () => void;
|
||||
onOpenImportClaude: () => void;
|
||||
onOpenImportGemini: () => void;
|
||||
onOpenImportGrokCli: () => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
@@ -42,14 +43,13 @@ export default function EmptyConnectionsPlaceholder({
|
||||
onOpenImportCodex,
|
||||
onOpenImportClaude,
|
||||
onOpenImportGemini,
|
||||
onOpenImportGrokCli,
|
||||
t,
|
||||
}: EmptyConnectionsPlaceholderProps) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">
|
||||
{isOAuth ? "lock" : "key"}
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[32px]">{isOAuth ? "lock" : "key"}</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">{t("noConnectionsYet")}</p>
|
||||
<p className="text-sm text-text-muted mb-4">{t("addFirstConnectionHint")}</p>
|
||||
@@ -82,10 +82,7 @@ export default function EmptyConnectionsPlaceholder({
|
||||
{providerSupportsPat ? "Add PAT" : t("addConnection")}
|
||||
</Button>
|
||||
{providerId === "qoder" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => gateConnectionFlow(onOpenOAuthModal)}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => gateConnectionFlow(onOpenOAuthModal)}>
|
||||
Experimental OAuth
|
||||
</Button>
|
||||
)}
|
||||
@@ -122,6 +119,15 @@ export default function EmptyConnectionsPlaceholder({
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "grok-cli" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportGrokCli)}
|
||||
>
|
||||
Import auth
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { AdaptaTutorialModal } from "./AdaptaTutorialModal";
|
||||
import { ImportCodexAuthModal, ApplyCodexAuthModal } from "./modals/ImportCodexAuthModal";
|
||||
import { ImportClaudeAuthModal, ApplyClaudeAuthModal } from "./modals/ImportClaudeAuthModal";
|
||||
import { ImportGeminiAuthModal, ApplyGeminiAuthModal } from "./modals/ImportGeminiAuthModal";
|
||||
import ImportGrokCliAuthModal from "./modals/ImportGrokCliAuthModal";
|
||||
import { type ConnectionRowConnection } from "./ConnectionRow";
|
||||
import { type BatchTestResults } from "../hooks/useProviderConnections";
|
||||
import { type ImportProgress } from "../hooks/useModelImportHandlers";
|
||||
@@ -123,6 +124,9 @@ interface ProviderModalsPanelProps {
|
||||
handleApplyGeminiAuthLocal: (id: string) => Promise<void>;
|
||||
importGeminiModalOpen: boolean;
|
||||
setImportGeminiModalOpen: (open: boolean) => void;
|
||||
// Grok Build auth
|
||||
importGrokCliModalOpen: boolean;
|
||||
setImportGrokCliModalOpen: (open: boolean) => void;
|
||||
// Batch test results
|
||||
batchTestResults: BatchTestResults | null;
|
||||
setBatchTestResults: (r: BatchTestResults | null) => void;
|
||||
@@ -209,6 +213,8 @@ export default function ProviderModalsPanel({
|
||||
handleApplyGeminiAuthLocal,
|
||||
importGeminiModalOpen,
|
||||
setImportGeminiModalOpen,
|
||||
importGrokCliModalOpen,
|
||||
setImportGrokCliModalOpen,
|
||||
batchTestResults,
|
||||
setBatchTestResults,
|
||||
emailsVisible,
|
||||
@@ -395,6 +401,16 @@ export default function ProviderModalsPanel({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{providerId === "grok-cli" && importGrokCliModalOpen && (
|
||||
<ImportGrokCliAuthModal
|
||||
key="import-grok-cli-modal"
|
||||
onClose={() => setImportGrokCliModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setImportGrokCliModalOpen(false);
|
||||
void fetchConnections();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<BatchTestResultsModal
|
||||
batchTestResults={batchTestResults}
|
||||
providerInfo={providerInfo}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
|
||||
interface ImportGrokCliAuthModalProps {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function parseGrokJson(json: unknown): {
|
||||
valid: boolean;
|
||||
email: string | null;
|
||||
hasRefreshToken: boolean;
|
||||
} {
|
||||
try {
|
||||
const doc = json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||
if (!doc) return { valid: false, email: null, hasRefreshToken: false };
|
||||
|
||||
const entries = Object.values(doc);
|
||||
for (const entry of entries) {
|
||||
if (entry && typeof entry === "object" && "key" in entry) {
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const key = typeof obj.key === "string" ? obj.key : null;
|
||||
const email = typeof obj.email === "string" ? obj.email : null;
|
||||
const hasRefreshToken =
|
||||
typeof obj.refresh_token === "string" && obj.refresh_token.length > 0;
|
||||
if (key && key.startsWith("eyJ")) {
|
||||
return { valid: true, email, hasRefreshToken };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { valid: false, email: null, hasRefreshToken: false };
|
||||
} catch {
|
||||
return { valid: false, email: null, hasRefreshToken: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default function ImportGrokCliAuthModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ImportGrokCliAuthModalProps) {
|
||||
const notify = useNotificationStore();
|
||||
const [tab, setTab] = useState<"upload" | "paste">("upload");
|
||||
const [parsedJson, setParsedJson] = useState<unknown>(null);
|
||||
const [detectedEmail, setDetectedEmail] = useState<string | null>(null);
|
||||
const [hasRefreshToken, setHasRefreshToken] = useState(false);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handlePreview(json: unknown) {
|
||||
setParseError(null);
|
||||
setDetectedEmail(null);
|
||||
setParsedJson(null);
|
||||
setHasRefreshToken(false);
|
||||
const result = parseGrokJson(json);
|
||||
if (!result.valid) {
|
||||
setParseError(
|
||||
"Not a valid Grok Build auth.json. Expected an object with a key containing a JWT."
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDetectedEmail(result.email);
|
||||
setHasRefreshToken(result.hasRefreshToken);
|
||||
setParsedJson(json);
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
try {
|
||||
handlePreview(JSON.parse(ev.target?.result as string));
|
||||
} catch {
|
||||
setParseError("Could not parse JSON");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function handlePasteChange(text: string) {
|
||||
setPasteText(text);
|
||||
if (!text.trim()) {
|
||||
setParsedJson(null);
|
||||
setParseError(null);
|
||||
setDetectedEmail(null);
|
||||
setHasRefreshToken(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handlePreview(JSON.parse(text));
|
||||
} catch {
|
||||
setParseError("Could not parse JSON");
|
||||
setParsedJson(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!parsedJson) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/oauth/grok-cli/import-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: parsedJson }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to import");
|
||||
return;
|
||||
}
|
||||
notify.success("Grok Build connection imported successfully");
|
||||
onSuccess();
|
||||
} catch {
|
||||
setError("Failed to import Grok Build auth");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title="Import Grok Build Auth" size="md">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
Import your Grok Build <code>~/.grok/auth.json</code> file. You can get it by running{" "}
|
||||
<code>grok login</code> in your terminal.
|
||||
</p>
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="flex gap-2 border-b border-border pb-3">
|
||||
<button
|
||||
className={`text-sm px-3 py-1 rounded-t ${tab === "upload" ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
||||
onClick={() => setTab("upload")}
|
||||
>
|
||||
Upload file
|
||||
</button>
|
||||
<button
|
||||
className={`text-sm px-3 py-1 rounded-t ${tab === "paste" ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`}
|
||||
onClick={() => setTab("paste")}
|
||||
>
|
||||
Paste JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload tab */}
|
||||
{tab === "upload" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-text-muted file:mr-3 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-primary/10 file:text-primary hover:file:bg-primary/20 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paste tab */}
|
||||
{tab === "paste" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
className="w-full h-32 p-3 text-sm font-mono bg-input border border-border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder='{"https://auth.x.ai::clientId": {"key": "eyJ...", ...}}'
|
||||
value={pasteText}
|
||||
onChange={(e) => handlePasteChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parse error */}
|
||||
{parseError && <p className="text-sm text-red-500">{parseError}</p>}
|
||||
|
||||
{/* Detected info */}
|
||||
{parsedJson && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-md p-3">
|
||||
<p className="text-sm text-green-400">
|
||||
Valid Grok Build token detected{detectedEmail ? ` (${detectedEmail})` : ""}
|
||||
</p>
|
||||
{hasRefreshToken && (
|
||||
<p className="text-xs text-green-500 mt-1">
|
||||
Refresh token included — automatic token renewal enabled
|
||||
</p>
|
||||
)}
|
||||
{!hasRefreshToken && (
|
||||
<p className="text-xs text-amber-400 mt-1">
|
||||
No refresh token found — you will need to re-import when the token expires
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Connection name (optional)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-input border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit error */}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!parsedJson || loading}>
|
||||
{loading ? "Saving…" : "Save Connection"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -83,11 +83,7 @@ export interface UseProviderConnectionsReturn {
|
||||
handleUpdateConnectionStatus: (id: string, isActive: boolean) => Promise<void>;
|
||||
handleToggleRateLimit: (connectionId: string, enabled: boolean) => Promise<void>;
|
||||
handleToggleClaudeExtraUsage: (connectionId: string, enabled: boolean) => Promise<void>;
|
||||
handleToggleCodexLimit: (
|
||||
connectionId: string,
|
||||
field: string,
|
||||
enabled: boolean
|
||||
) => Promise<void>;
|
||||
handleToggleCodexLimit: (connectionId: string, field: string, enabled: boolean) => Promise<void>;
|
||||
handleToggleCliproxyapiMode: (connectionId: string, enabled: boolean) => Promise<void>;
|
||||
handleToggleProxyEnabled: (connectionId: string, proxyEnabled: boolean) => Promise<void>;
|
||||
handleTogglePerKeyProxyEnabled: (
|
||||
@@ -101,9 +97,7 @@ export interface UseProviderConnectionsReturn {
|
||||
// Batch handlers
|
||||
handleBatchSetActive: (isActive: boolean) => Promise<void>;
|
||||
handleBatchDeleteOpenModal: () => void;
|
||||
handleBatchDeleteConfirm: (
|
||||
onAfter?: () => Promise<void>
|
||||
) => Promise<void>;
|
||||
handleBatchDeleteConfirm: (onAfter?: () => Promise<void>) => Promise<void>;
|
||||
handleBatchRetest: () => Promise<void>;
|
||||
handleBatchTestAll: () => Promise<void>;
|
||||
|
||||
@@ -198,8 +192,7 @@ export function useProviderConnections(
|
||||
setConnections(filtered);
|
||||
}
|
||||
if (nodesRes.ok) {
|
||||
let node =
|
||||
(nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
let node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
|
||||
// Newly created compatible nodes can be briefly unavailable on one worker.
|
||||
if (!node && isCompatible) {
|
||||
@@ -208,8 +201,7 @@ export function useProviderConnections(
|
||||
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
|
||||
if (!retryRes.ok) continue;
|
||||
const retryData = await retryRes.json();
|
||||
node =
|
||||
(retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
|
||||
if (node) break;
|
||||
}
|
||||
}
|
||||
@@ -230,10 +222,7 @@ export function useProviderConnections(
|
||||
conns
|
||||
.filter((c) => c.id)
|
||||
.map((c) =>
|
||||
fetch(
|
||||
`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`,
|
||||
{ cache: "no-store" }
|
||||
)
|
||||
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => [c.id!, data] as [string, any])
|
||||
.catch(() => [c.id!, null] as [string, any])
|
||||
@@ -347,9 +336,7 @@ export function useProviderConnections(
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections((prev: any[]) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, isActive } : c))
|
||||
);
|
||||
setConnections((prev: any[]) => prev.map((c) => (c.id === id ? { ...c, isActive } : c)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error updating connection status:", error);
|
||||
@@ -365,9 +352,7 @@ export function useProviderConnections(
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections((prev: any[]) =>
|
||||
prev.map((c) =>
|
||||
c.id === connectionId ? { ...c, rateLimitProtection: enabled } : c
|
||||
)
|
||||
prev.map((c) => (c.id === connectionId ? { ...c, rateLimitProtection: enabled } : c))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -434,11 +419,7 @@ export function useProviderConnections(
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCodexLimit = async (
|
||||
connectionId: string,
|
||||
field: string,
|
||||
enabled: boolean
|
||||
) => {
|
||||
const handleToggleCodexLimit = async (connectionId: string, field: string, enabled: boolean) => {
|
||||
try {
|
||||
const target = (connections as any[]).find((connection) => connection.id === connectionId);
|
||||
if (!target) return;
|
||||
@@ -561,7 +542,7 @@ export function useProviderConnections(
|
||||
const res = await fetch(`/api/providers/${connectionId}/test`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || t("failedRetestConnection"));
|
||||
notify.error(data.error || t("failedRetestConnection"));
|
||||
return;
|
||||
}
|
||||
await fetchConnections();
|
||||
@@ -641,10 +622,7 @@ export function useProviderConnections(
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
setSelectedIds((prev) => {
|
||||
if (
|
||||
prev.size === (connections as any[]).length &&
|
||||
(connections as any[]).length > 0
|
||||
) {
|
||||
if (prev.size === (connections as any[]).length && (connections as any[]).length > 0) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set((connections as any[]).map((c: { id: string }) => c.id));
|
||||
|
||||
@@ -63,7 +63,7 @@ const BROWSER_DEVICE_FLOW_PROVIDERS = new Set(["codex"]);
|
||||
const RETIRED_PKCE_PROVIDERS = new Set(["windsurf", "devin-cli"]);
|
||||
|
||||
/** Providers that allow direct import of a raw API token (no OAuth exchange). */
|
||||
const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli"]);
|
||||
const IMPORT_TOKEN_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
|
||||
|
||||
/**
|
||||
* Constant-time string comparison to prevent timing-oracle attacks (CWE-208).
|
||||
|
||||
109
src/lib/oauth/providers/grok-cli.ts
Normal file
109
src/lib/oauth/providers/grok-cli.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Grok Build OAuth Provider — Import Token Flow with Refresh Support
|
||||
*
|
||||
* 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 { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds";
|
||||
|
||||
const GROK_CLI_CONFIG = {
|
||||
clientId: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
|
||||
tokenUrl: "https://auth.x.ai/oauth2/token",
|
||||
};
|
||||
|
||||
interface GrokCliAuthInfo {
|
||||
user_id: string;
|
||||
email: string;
|
||||
team_id: string;
|
||||
tier: number;
|
||||
principal_type: string;
|
||||
}
|
||||
|
||||
function parseJwtPayload(token: string): {
|
||||
email: string | null;
|
||||
authInfo: GrokCliAuthInfo | null;
|
||||
} {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return { email: null, authInfo: null };
|
||||
|
||||
let base64 = parts[1];
|
||||
switch (base64.length % 4) {
|
||||
case 2:
|
||||
base64 += "==";
|
||||
break;
|
||||
case 3:
|
||||
base64 += "=";
|
||||
break;
|
||||
}
|
||||
base64 = base64.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { email: null, authInfo: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the JWT access token and refresh_token from user input.
|
||||
* Accepts either:
|
||||
* - Raw JWT string (no refresh_token available)
|
||||
* - The entire auth.json object: { "https://auth.x.ai::...": { "key": "eyJ...", "refresh_token": "..." } }
|
||||
*/
|
||||
function extractTokenAndRefresh(input: any): { accessToken: string; refreshToken: string | null } {
|
||||
if (typeof input === "string") return { accessToken: input, refreshToken: null };
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
// auth.json format: first entry's "key" and "refresh_token" fields
|
||||
const keys = Object.keys(input);
|
||||
if (keys.length > 0 && input[keys[0]]?.key) {
|
||||
return {
|
||||
accessToken: input[keys[0]].key,
|
||||
refreshToken: input[keys[0]].refresh_token || null,
|
||||
};
|
||||
}
|
||||
// Already has accessToken
|
||||
if (input.accessToken) {
|
||||
return {
|
||||
accessToken: input.accessToken,
|
||||
refreshToken: input.refreshToken || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { accessToken: "", refreshToken: null };
|
||||
}
|
||||
|
||||
export const grokCli = {
|
||||
config: GROK_CLI_CONFIG,
|
||||
flowType: "import_token",
|
||||
mapTokens: (token: any) => {
|
||||
const { accessToken, refreshToken } = extractTokenAndRefresh(token);
|
||||
const { email, authInfo } = parseJwtPayload(accessToken);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: 21600,
|
||||
email,
|
||||
providerSpecificData: {
|
||||
userId: authInfo?.user_id || null,
|
||||
teamId: authInfo?.team_id || null,
|
||||
tier: authInfo?.tier || 1,
|
||||
principalType: authInfo?.principal_type || "User",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import { trae } from "./trae";
|
||||
import { kilocode } from "./kilocode";
|
||||
import { cline } from "./cline";
|
||||
import { windsurf } from "./windsurf";
|
||||
import { grokCli } from "./grok-cli";
|
||||
import { codebuddyCn } from "./codebuddy-cn";
|
||||
|
||||
export const PROVIDERS = {
|
||||
@@ -48,6 +49,7 @@ export const PROVIDERS = {
|
||||
windsurf,
|
||||
// devin-cli shares the same token format as windsurf (WINDSURF_API_KEY / devin auth login)
|
||||
"devin-cli": windsurf,
|
||||
"grok-cli": grokCli,
|
||||
"codebuddy-cn": codebuddyCn,
|
||||
};
|
||||
|
||||
|
||||
@@ -84,6 +84,18 @@ const KNOWN_PLANS: Record<string, KnownPlanShape> = {
|
||||
provider: "alibaba",
|
||||
dimensions: [{ unit: "requests", window: "monthly", limit: 90_000 }],
|
||||
},
|
||||
// Grok Build (xAI) — rate limits from x-ratelimit-* headers:
|
||||
// Daily: 864 requests, 18M tokens (from API headers)
|
||||
// Weekly: derived from daily * 7
|
||||
"grok-cli": {
|
||||
provider: "grok-cli",
|
||||
dimensions: [
|
||||
{ unit: "requests", window: "daily", limit: 864 },
|
||||
{ unit: "tokens", window: "daily", limit: 18_000_000 },
|
||||
{ unit: "requests", window: "weekly", limit: 6048 },
|
||||
{ unit: "tokens", window: "weekly", limit: 126_000_000 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getKnownPlan(provider: string): KnownPlanShape | null {
|
||||
|
||||
@@ -19,7 +19,7 @@ const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex"]);
|
||||
* Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser.
|
||||
* Spec: docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md.
|
||||
*/
|
||||
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli"]);
|
||||
const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]);
|
||||
|
||||
type OAuthModalProps = {
|
||||
isOpen: boolean;
|
||||
@@ -55,12 +55,13 @@ export default function OAuthModal({
|
||||
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 === "windsurf" || provider === "devin-cli" || provider === "grok-cli"
|
||||
);
|
||||
const [pasteToken, setPasteToken] = useState("");
|
||||
const [savingToken, setSavingToken] = useState(false);
|
||||
|
||||
const supportsTokenPaste = provider === "windsurf" || provider === "devin-cli";
|
||||
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 importTokenOnly = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
|
||||
@@ -731,14 +732,16 @@ export default function OAuthModal({
|
||||
<p className="text-sm text-text-muted">
|
||||
{provider === "windsurf"
|
||||
? 'In the Windsurf / VS Code IDE, run the "Windsurf: Provide Auth Token" command from the command palette (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it below. Opening windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the flow.'
|
||||
: 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}
|
||||
: provider === "grok-cli"
|
||||
? 'Paste your Grok Build JWT token from ~/.grok/auth.json (the "key" field value). You can get it by running `grok login` in your terminal.'
|
||||
: 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}
|
||||
</p>
|
||||
<Input
|
||||
value={pasteToken}
|
||||
onChange={(e) => setPasteToken(e.target.value)}
|
||||
placeholder="ws-..."
|
||||
placeholder={provider === "grok-cli" ? "eyJ..." : "ws-..."}
|
||||
type="password"
|
||||
label="API Key / Token"
|
||||
label={provider === "grok-cli" ? "JWT Token" : "API Key / Token"}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -51,8 +51,6 @@ export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean {
|
||||
return typeof providerId === "string" && FREE_APIKEY_PROVIDER_IDS.has(providerId);
|
||||
}
|
||||
|
||||
// OAuth Providers
|
||||
|
||||
// Web / Cookie Providers
|
||||
|
||||
// API Key Providers
|
||||
@@ -141,8 +139,6 @@ export function isAnthropicCompatibleProvider(providerId: unknown): providerId i
|
||||
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isClaudeCodeCompatibleProvider(providerId: unknown): providerId is string {
|
||||
return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
67
tests/unit/grok-cli-oauth.test.ts
Normal file
67
tests/unit/grok-cli-oauth.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { grokCli } = await import("../../src/lib/oauth/providers/grok-cli.ts");
|
||||
const { resolvePublicCred } = await import("@omniroute/open-sse/utils/publicCreds");
|
||||
|
||||
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),
|
||||
// not a string literal — assert it matches resolvePublicCred("grok_id").
|
||||
assert.equal(
|
||||
grokCli.config.clientId,
|
||||
resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"),
|
||||
"clientId must resolve from the embedded grok_id default"
|
||||
);
|
||||
assert.equal(grokCli.config.tokenUrl, "https://auth.x.ai/oauth2/token");
|
||||
});
|
||||
|
||||
test("publicCreds: grok_id embedded default is present and decodes", () => {
|
||||
const decoded = resolvePublicCred("grok_id");
|
||||
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 - mapTokens from raw JWT", () => {
|
||||
// Create a valid JWT with base64url-encoded payload
|
||||
const payload = { sub: "12345", email: "test@example.com", team_id: "team-67890", tier: 1 };
|
||||
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const mockJwt = `eyJhbGciOiJFUzI1NiJ9.${payloadBase64}.signature`;
|
||||
const result = grokCli.mapTokens(mockJwt, null);
|
||||
|
||||
assert.equal(result.accessToken, mockJwt);
|
||||
assert.equal(result.refreshToken, null);
|
||||
assert.equal(result.email, "test@example.com");
|
||||
assert.equal(result.expiresIn, 21600);
|
||||
assert.equal(result.providerSpecificData?.userId, "12345");
|
||||
assert.equal(result.providerSpecificData?.teamId, "team-67890");
|
||||
assert.equal(result.providerSpecificData?.tier, 1);
|
||||
});
|
||||
|
||||
test("Grok Build OAuth Provider - mapTokens from auth.json", () => {
|
||||
const authJson = {
|
||||
"https://auth.x.ai::clientId": {
|
||||
key: "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
|
||||
refresh_token: "test-refresh-token",
|
||||
},
|
||||
};
|
||||
const result = grokCli.mapTokens(authJson, null);
|
||||
|
||||
assert.ok(result.accessToken.includes("eyJ"), "accessToken should be JWT");
|
||||
assert.equal(result.refreshToken, "test-refresh-token");
|
||||
assert.equal(result.email, "test@example.com");
|
||||
});
|
||||
|
||||
test("Grok Build OAuth Provider - mapTokens from empty string", () => {
|
||||
const result = grokCli.mapTokens("", null);
|
||||
assert.equal(result.accessToken, "");
|
||||
});
|
||||
|
||||
test("Grok Build OAuth Provider - mapTokens from object with accessToken", () => {
|
||||
const input = { accessToken: "direct-token" };
|
||||
const result = grokCli.mapTokens(input, null);
|
||||
assert.equal(result.accessToken, "direct-token");
|
||||
});
|
||||
Reference in New Issue
Block a user