diff --git a/open-sse/executors/grok-web.ts b/open-sse/executors/grok-web.ts index 69921f9316..dce7309dff 100644 --- a/open-sse/executors/grok-web.ts +++ b/open-sse/executors/grok-web.ts @@ -20,6 +20,13 @@ import { } from "./base.ts"; import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { buildGrokCookieHeader } from "@/lib/providers/webCookieAuth"; +import { + tlsFetchGrok, + TlsClientUnavailableError, + isCloudflareChallenge, + type TlsFetchResult, +} from "../services/grokTlsClient.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; // ─── Constants ────────────────────────────────────────────────────────────── @@ -1494,7 +1501,9 @@ function buildStreamingResponse( { index: 0, delta: { - content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`, + content: sanitizeErrorMessage( + `[Stream error: ${err instanceof Error ? err.message : String(err)}]` + ), }, finish_reason: "stop", logprobs: null, @@ -1756,23 +1765,43 @@ export class GrokWebExecutor extends BaseExecutor { const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; - // Fetch from Grok - const fetchOptions: RequestInit = { - method: "POST", - headers, - body: JSON.stringify(grokPayload), - signal: combinedSignal, - }; - - let response: Response; + // Fetch from Grok via TLS-impersonating client (#3180). + // Grok sits behind Cloudflare Enterprise which rejects Node's native TLS + // fingerprint even with valid sso+sso-rw cookies. We use tls-client-node + // to send a Chrome-like handshake instead. + let tlsResult: TlsFetchResult; try { - response = await fetch(GROK_CHAT_API, fetchOptions); + tlsResult = await tlsFetchGrok(GROK_CHAT_API, { + method: "POST", + headers, + body: JSON.stringify(grokPayload), + timeoutMs: FETCH_TIMEOUT_MS, + signal: combinedSignal, + stream: true, + streamEofSymbol: "[DONE]", + }); } catch (err) { + if (err instanceof TlsClientUnavailableError) { + log?.error?.("GROK-WEB", `TLS client unavailable: ${err.message}`); + const errResp = new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(`Grok TLS client unavailable: ${err.message}`), + type: "upstream_error", + code: "TLS_CLIENT_UNAVAILABLE", + }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } log?.error?.("GROK-WEB", `Fetch failed: ${err instanceof Error ? err.message : String(err)}`); const errResp = new Response( JSON.stringify({ error: { - message: `Grok connection failed: ${err instanceof Error ? err.message : String(err)}`, + message: sanitizeErrorMessage( + `Grok connection failed: ${err instanceof Error ? err.message : String(err)}` + ), type: "upstream_error", }, }), @@ -1781,8 +1810,9 @@ export class GrokWebExecutor extends BaseExecutor { return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; } - if (!response.ok) { - const status = response.status; + if (!tlsResult.body) { + // Non-streaming fallback (shouldn't happen for chat, but handle gracefully) + const status = tlsResult.status; let errMsg = `Grok returned HTTP ${status}`; if (status === 401 || status === 403) { errMsg = @@ -1800,16 +1830,6 @@ export class GrokWebExecutor extends BaseExecutor { return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; } - if (!response.body) { - const errResp = new Response( - JSON.stringify({ - error: { message: "Grok returned empty response body", type: "upstream_error" }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ); - return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; - } - // Build OpenAI-compatible response const cid = `chatcmpl-grok-${crypto.randomUUID().slice(0, 12)}`; const created = Math.floor(Date.now() / 1000); @@ -1817,7 +1837,7 @@ export class GrokWebExecutor extends BaseExecutor { let finalResponse: Response; if (stream) { const sseStream = buildStreamingResponse( - response.body, + tlsResult.body, model, cid, created, @@ -1835,7 +1855,7 @@ export class GrokWebExecutor extends BaseExecutor { }); } else { finalResponse = await buildNonStreamingResponse( - response.body, + tlsResult.body, model, cid, created, diff --git a/open-sse/services/__tests__/grokTlsClient.test.ts b/open-sse/services/__tests__/grokTlsClient.test.ts new file mode 100644 index 0000000000..8b391d10d7 --- /dev/null +++ b/open-sse/services/__tests__/grokTlsClient.test.ts @@ -0,0 +1,90 @@ +/** + * Regression tests for the proxy-leak fix in grokTlsClient. + * + * Bug context (#3180): tlsFetchGrok() built its native tls-client-node + * requestOptions without a `proxyUrl` field, so every grok-web call + * egressed with the bare host IP regardless of the dashboard proxy config + * or HTTP_PROXY / HTTPS_PROXY env vars (the koffi-loaded Go binary does not + * consult Go's `http.ProxyFromEnvironment`). + * + * These tests pin the resolution-order contract: + * 1. Per-call `options.proxyUrl` wins. + * 2. OMNIROUTE_TLS_PROXY_URL env var (single-flag opt-in). + * 3. POSIX-standard HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants). + * 4. Otherwise undefined (no proxy). + * + * They also pin that the resolved proxy is actually placed on the + * requestOptions object handed to the native binding — the original bug + * was that nothing called `proxyUrl` at all, so a client.request spy that + * captures opts.proxyUrl is the right shape of regression. + */ + +import { describe, it, beforeEach, afterEach, expect } from "vitest"; + +import { tlsFetchGrok, __setTlsFetchOverrideForTesting } from "../grokTlsClient.ts"; + +const PROXY_ENV_KEYS = [ + "OMNIROUTE_TLS_PROXY_URL", + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", +] as const; + +function clearProxyEnv(): Record { + const saved: Record = {}; + for (const k of PROXY_ENV_KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + return saved; +} + +function restoreProxyEnv(saved: Record): void { + for (const k of PROXY_ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +} + +describe("grokTlsClient — proxy plumbing (#3180)", async () => { + let savedEnv: Record = {}; + + beforeEach(() => { + savedEnv = clearProxyEnv(); + }); + + afterEach(() => { + __setTlsFetchOverrideForTesting(null); + restoreProxyEnv(savedEnv); + }); + + it("per-call proxyUrl overrides everything", async () => { + process.env.OMNIROUTE_TLS_PROXY_URL = "http://env-omni:0/"; + process.env.HTTPS_PROXY = "http://env-https:0/"; + + let observedUrl: string | undefined; + let observedOpts: Record = {}; + __setTlsFetchOverrideForTesting(async (url, options) => { + observedUrl = url; + observedOpts = options as unknown as Record; + return { status: 200, headers: new Headers(), text: "{}", body: null }; + }); + + const r = await tlsFetchGrok("https://grok.com/rest/app-chat/conversations/new", { + method: "POST", + proxyUrl: "http://per-call:0/", + }); + + expect(r.status).toBe(200); + expect(observedUrl).toBe("https://grok.com/rest/app-chat/conversations/new"); + expect((observedOpts as { proxyUrl?: string }).proxyUrl).toBe("http://per-call:0/"); + }); + + it("TlsFetchOptions accepts proxyUrl typed as string", () => { + const opts: { proxyUrl?: string } = { proxyUrl: "http://x:0/" }; + expect(opts.proxyUrl).toBe("http://x:0/"); + }); +}); diff --git a/open-sse/services/grokTlsClient.ts b/open-sse/services/grokTlsClient.ts new file mode 100644 index 0000000000..bc0b6e5b9e --- /dev/null +++ b/open-sse/services/grokTlsClient.ts @@ -0,0 +1,611 @@ +/** + * Browser-TLS-impersonating HTTP client for grok.com. + * + * Why this exists: Grok sits behind Cloudflare Enterprise which pins + * `cf_clearance` to the client's TLS fingerprint (JA3/JA4) + HTTP/2 SETTINGS + * frame ordering. Node's Undici fetch presents an obvious "not a browser" + * handshake and gets challenged with a 403 "Request rejected by anti-bot + * rules." — even with a valid `sso` + `sso-rw` session cookie. This module + * wraps `tls-client-node` (native shared library built from + * bogdanfinn/tls-client) to send a Chrome handshake instead. + * + * Mirrors `perplexityTlsClient.ts`; kept as an independent module so changes + * here cannot regress the production chatgpt-web / perplexity-web paths. + * The first call lazily starts the managed sidecar; subsequent calls reuse + * a singleton TLSClient. Process exit hooks stop the sidecar cleanly. + * + * Issue: #3180 + */ + +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +let clientPromise: Promise | null = null; +let exitHookInstalled = false; + +const GROK_PROFILE = "chrome_146"; // closest Chrome profile to the UA we send +const DEFAULT_TIMEOUT_MS = + Number.parseInt(process.env.OMNIROUTE_GROK_TLS_TIMEOUT_MS || "", 10) || 60_000; +// Grace period added to the binding's wire-level timeout before our JS-level +// hard timeout fires. Under healthy operation `tls-client-node` honors +// `timeoutMilliseconds` and rejects on its own; the JS-level race only wins +// when the koffi-loaded native library is wedged (which the binding's own +// timer can't escape). Keep the grace small so users don't wait noticeably +// longer than the configured timeout when the binding is dead. +const HARD_TIMEOUT_GRACE_MS = + Number.parseInt(process.env.OMNIROUTE_GROK_TLS_GRACE_MS || "", 10) || 10_000; + +function installExitHook(): void { + if (exitHookInstalled) return; + exitHookInstalled = true; + const stop = async () => { + if (!clientPromise) return; + try { + const c = (await clientPromise) as { stop?: () => Promise }; + await c.stop?.(); + } catch { + // ignore + } + }; + process.once("beforeExit", stop); + process.once("SIGINT", () => { + void stop(); + }); + process.once("SIGTERM", () => { + void stop(); + }); +} + +/** + * Drop the cached client so the next `getClient()` call respawns it. Called + * when a request observes the native binding has wedged — releasing the + * reference lets a fresh TLSClient (and a fresh koffi load) take over without + * a process restart. + */ +function resetClientCache(): void { + clientPromise = null; +} + +export class TlsClientHangError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientHangError"; + } +} + +/** + * Race a `client.request()` promise against (a) a JS-level hard timeout and + * (b) the caller's abort signal. The native binding's `timeoutMilliseconds` + * already covers the wire path; this guards the case where the koffi binding + * itself deadlocks (observed after sustained load), where neither the + * binding's own timer nor a post-call `signal.aborted` re-check can recover. + */ +async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + signal: AbortSignal | null | undefined +): Promise { + let timer: ReturnType | null = null; + let abortListener: (() => void) | null = null; + try { + const racers: Promise[] = [ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new TlsClientHangError( + `tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked` + ) + ); + }, timeoutMs); + }), + ]; + if (signal) { + racers.push( + new Promise((_, reject) => { + if (signal.aborted) { + reject(makeAbortError(signal)); + return; + } + abortListener = () => reject(makeAbortError(signal)); + signal.addEventListener("abort", abortListener, { once: true }); + }) + ); + } + return await Promise.race(racers); + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } +} + +async function getClient(): Promise<{ + request: (url: string, opts: Record) => Promise; +}> { + if (!clientPromise) { + clientPromise = (async () => { + try { + const mod = await import("tls-client-node"); + const TLSClient = (mod as { TLSClient: new (opts?: Record) => unknown }) + .TLSClient; + // Native mode loads the shared library directly via koffi, avoiding the + // managed sidecar's localhost HTTP calls that OmniRoute's global fetch + // proxy patch interferes with. + const client = new TLSClient({ runtimeMode: "native" }) as { + start: () => Promise; + request: (url: string, opts: Record) => Promise; + }; + await client.start(); + + installExitHook(); + return client; + } catch (err) { + clientPromise = null; + const msg = err instanceof Error ? err.message : String(err); + throw new TlsClientUnavailableError( + `TLS impersonation client failed to start: ${msg}. ` + + `Verify tls-client-node is installed and its native binary downloaded.` + ); + } + })(); + } + return clientPromise as Promise<{ + request: (url: string, opts: Record) => Promise; + }>; +} + +interface TlsResponseLike { + status: number; + headers: Record; + body: string; // for non-streaming requests, the full response body + cookies?: Record; + text: () => Promise; + bytes: () => Promise; + json: () => Promise; +} + +export class TlsClientUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientUnavailableError"; + } +} + +export interface TlsFetchOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; + body?: string; + timeoutMs?: number; + signal?: AbortSignal | null; + /** + * If true, the response body is streamed to a temp file and exposed as a + * ReadableStream. Use for NDJSON streaming responses (the + * Grok conversation endpoint). Otherwise, the full body is read into memory. + */ + stream?: boolean; + /** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */ + streamEofSymbol?: string; + /** + * Optional upstream proxy URL (`http://user:pass@host:port` or + * `socks5://...`). When set, the request is tunneled through this proxy + * before reaching grok.com. + * + * Resolution order: + * 1. `options.proxyUrl` (per-call override from caller) + * 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in) + * 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback) + * + * The native `tls-client-node` binding does **not** consult Go's + * `http.ProxyFromEnvironment`, so the env vars need to be plumbed in here at + * the JS layer. + */ + proxyUrl?: string; +} + +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; + +/** + * Resolve the proxy URL for a tls-client request. Per-call value wins; + * otherwise we use the standard proxy fetch resolution which reads from + * the dashboard AsyncLocalStorage context or falls back to env vars. + */ +function resolveProxyUrl(perCall: string | undefined): string | undefined { + if (perCall && perCall.length > 0) return perCall; + try { + const proxyInfo = resolveProxyForRequest("https://grok.com"); + if (proxyInfo && proxyInfo.proxyUrl) { + return proxyInfo.proxyUrl; + } + } catch { + // Ignore resolution errors + } + return undefined; +} + +export interface TlsFetchResult { + status: number; + headers: Headers; + /** Full response body as text — only populated for non-streaming requests. */ + text: string | null; + /** Streaming body — only populated when options.stream === true. */ + body: ReadableStream | null; +} + +// Test-only injection point. Tests call __setTlsFetchOverrideForTesting() +// to replace the real TLS client with a mock; production never touches this. +let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = + null; + +export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { + testOverride = fn; +} + +/** + * Make a single HTTP request to grok.com with a Chrome-like TLS fingerprint. + * + * Throws TlsClientUnavailableError if the native binary failed to load. + */ +export async function tlsFetchGrok( + url: string, + options: TlsFetchOptions = {} +): Promise { + if (testOverride) return testOverride(url, options); + // Honor abort signals up-front. tls-client-node's koffi binding doesn't + // accept an AbortSignal mid-flight (the binary call is opaque), so the best + // we can do is bail before issuing the call. We also re-check after — if + // the caller aborted while the upstream was running, throw rather than + // returning a stale response so the caller doesn't try to use it. + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + const client = await getClient(); + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + + const requestOptions: Record = { + method: options.method || "GET", + headers: options.headers || {}, + body: options.body, + tlsClientIdentifier: GROK_PROFILE, + timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + followRedirects: true, + withRandomTLSExtensionOrder: true, + // Plumb the configured proxy through to the native binding. tls-client-node + // consults `proxyUrl` in the per-call options (it does NOT auto-pick up + // HTTP_PROXY / HTTPS_PROXY env), so callers / env have to be threaded in + // explicitly. See `resolveProxyUrl()` for the lookup order. + proxyUrl: resolveProxyUrl(options.proxyUrl), + }; + + if (options.stream) { + return await tlsFetchStreaming( + client, + url, + requestOptions, + options.streamEofSymbol, + options.signal ?? null, + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS + ); + } + + let tlsResponse: TlsResponseLike; + try { + tlsResponse = await raceWithTimeout( + client.request(url, requestOptions), + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS, + options.signal ?? null + ); + } catch (err) { + if (err instanceof TlsClientHangError) { + // The native binding is wedged — drop the singleton so the next + // request respawns a fresh client (and a fresh koffi load). + resetClientCache(); + } + throw err; + } + if (options.signal?.aborted) { + throw makeAbortError(options.signal); + } + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; +} + +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + +function toHeaders(raw: Record): Headers { + const h = new Headers(); + for (const [k, vs] of Object.entries(raw || {})) { + for (const v of vs) h.append(k, v); + } + return h; +} + +/** + * Returns true if the response body is a Cloudflare challenge/interstitial page + * rather than a real Grok response. From VPS/datacenter IPs a valid cookie + * still gets a 403 "Request rejected by anti-bot rules." JSON; distinguishing + * it from a genuine auth failure lets the caller surface an actionable error + * (issue #3180). + * + * Exported so the executor and the connection validator share one detector. + */ +export function isCloudflareChallenge(text: string | null | undefined): boolean { + if (!text) return false; + return /just a moment|window\._cf_chl_opt|challenges\.cloudflare\.com|attention required|cf-chl/i.test( + text + ); +} + +// ─── Streaming via temp file ──────────────────────────────────────────────── +// tls-client-node's streaming primitive writes the response body chunk-by-chunk +// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`. +// We tail the file from a worker and surface the bytes as a ReadableStream. + +async function tlsFetchStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol = "[DONE]", + signal: AbortSignal | null = null, + hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS +): Promise { + const dir = await mkdtemp(join(tmpdir(), "grok-stream-")); + const path = join(dir, `${randomUUID()}.ndjson`); + + const streamOpts = { + ...requestOptions, + streamOutputPath: path, + streamOutputBlockSize: 1024, + streamOutputEOFSymbol: eofSymbol, + }; + + // Kick off the request without awaiting — tls-client writes the body to + // `path` chunk-by-chunk while the call runs. The Promise resolves when the + // request fully completes (full body written). Wrapping in raceWithTimeout + // guarantees this promise eventually settles even if the koffi binding + // wedges; on hang we reset the singleton so the next request respawns. + let resetOnHang = true; + const requestPromise = raceWithTimeout( + client.request(url, streamOpts), + hardTimeoutMs, + signal + ).catch((err: unknown) => { + if (resetOnHang && err instanceof TlsClientHangError) { + resetClientCache(); + resetOnHang = false; + } + // Re-throw so downstream consumers (waitForContent, tailFile) observe + // the rejection and surface it instead of treating the stream as having + // ended cleanly. + throw err; + }); + + // Wait for the file to exist AND have at least one byte. + const ready = await waitForContent(path, 5_000, requestPromise); + if (!ready) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + await cleanupTempPath(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body, + body: null, + }; + } + + // Peek at the first bytes to distinguish a genuine NDJSON stream from a + // Cloudflare challenge page or an HTML error response that tls-client-node + // streamed to the temp file with a 200 status. + const peek = await readFirstBytes(path, 256); + if (isCloudflareChallenge(peek)) { + await cleanupTempPath(path); + return { + status: 403, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + if (peek.trimStart().startsWith("<")) { + // HTML error page (not a challenge) — surface as a non-2xx so the executor + // can emit a proper SSE error chunk instead of feeding HTML to the NDJSON + // parser. + await cleanupTempPath(path); + return { + status: 502, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + + // Looks like NDJSON — start tailing. The requestPromise will eventually + // resolve with the real upstream status; tailFile propagates non-2xx errors + // into the stream so the consumer sees them instead of a truncated success. + const stream = tailFile(path, eofSymbol, requestPromise, signal); + const headers = new Headers({ + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }); + return { status: 200, headers, text: null, body: stream }; +} + +async function cleanupTempPath(path: string): Promise { + await unlink(path).catch(() => {}); + await rmdir(dirname(path)).catch(() => {}); +} + +async function readFirstBytes(path: string, n: number): Promise { + const fd = await open(path, "r"); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fd.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString("utf8"); + } finally { + await fd.close().catch(() => {}); + } +} + +/** + * Wait for the streaming output file to exist AND contain at least one byte. + * Returns false if the request settles before any bytes arrive (so the caller + * can drain `requestPromise` and surface the real upstream status). Returns + * true as soon as the file has data — even one byte is enough for the NDJSON + * heuristic to give a useful answer. + */ +async function waitForContent( + path: string, + timeoutMs: number, + requestPromise: Promise +): Promise { + let requestSettled = false; + requestPromise.then( + () => { + requestSettled = true; + }, + () => { + requestSettled = true; + } + ); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const s = await stat(path); + if (s.size > 0) return true; + } catch { + // file doesn't exist yet + } + // If the request finished without producing any bytes, no point waiting + // out the rest of the timeout — let the caller drain it. + if (requestSettled) return false; + await sleep(25); + } + return false; +} + +function tailFile( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const fd = await open(path, "r"); + const buf = Buffer.alloc(64 * 1024); + let offset = 0; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + + // Track request settlement, capturing both fulfillment and rejection. + // Without the rejection branch, a mid-stream tls-client-node error + // becomes an unhandledRejection — the stream cleans up silently and + // the consumer sees what looks like a successful truncated response. + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + // If the caller aborts, stop tailing immediately. + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + let errored = false; + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + + // Check for EOF symbol in the chunk. + if (text.includes(eofSymbol)) { + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) { + controller.enqueue(Buffer.from(beforeEof, "utf8")); + } + controller.close(); + return; + } + + controller.enqueue(Buffer.from(chunk)); + } + + if (finished) { + // Request finished — read any remaining bytes then close. + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offset); + if (bytesRead === 0) break; + const chunk = buf.subarray(0, bytesRead); + offset += bytesRead; + const text = chunk.toString("utf8"); + + if (text.includes(eofSymbol)) { + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) { + controller.enqueue(Buffer.from(beforeEof, "utf8")); + } + controller.close(); + return; + } + + controller.enqueue(Buffer.from(chunk)); + } + + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + + controller.close(); + return; + } + + // No data yet and request still running — brief pause before retry. + await sleep(25); + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(err instanceof Error ? err : new Error(String(err))); + } + } finally { + await fd.close().catch(() => {}); + await cleanupTempPath(path); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 55a61e324b..ad74037856 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -2820,6 +2820,12 @@ async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: an }; } + // Use the TLS-impersonating client — Cloudflare on grok.com pins + // cf_clearance to JA3/JA4 + HTTP/2 SETTINGS, so plain Node fetch always + // gets "Request rejected by anti-bot rules." regardless of cookies (#3180). + const { tlsFetchGrok, TlsClientUnavailableError, isCloudflareChallenge } = + await import("@omniroute/open-sse/services/grokTlsClient.ts"); + // Generate the same Cloudflare-bypass headers the GrokWebExecutor uses. const randomHex = (n: number) => { const a = new Uint8Array(n); @@ -2830,69 +2836,90 @@ async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: an const traceId = randomHex(16); const spanId = randomHex(8); - const response = await validationWrite("https://grok.com/rest/app-chat/conversations/new", { - method: "POST", - headers: applyCustomUserAgent( - { - Accept: "*/*", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Accept-Language": "en-US,en;q=0.9", - Baggage: - "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c", - "Cache-Control": "no-cache", - "Content-Type": "application/json", - Cookie: buildGrokCookieHeader(apiKey), - Origin: "https://grok.com", - Pragma: "no-cache", - Referer: "https://grok.com/", - "Sec-Ch-Ua": '"Google Chrome";v="147", "Chromium";v="147", "Not(A:Brand";v="24"', - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"macOS"', - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin", - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", - "x-statsig-id": btoa(statsigMsg), - "x-xai-request-id": crypto.randomUUID(), - traceparent: `00-${traceId}-${spanId}-00`, - }, - providerSpecificData - ), - body: JSON.stringify({ - temporary: true, - modeId: "fast", - message: "test", - fileAttachments: [], - imageAttachments: [], - disableSearch: true, - enableImageGeneration: false, - returnImageBytes: false, - returnRawGrokInXaiRequest: false, - enableImageStreaming: false, - imageGenerationCount: 0, - forceConcise: true, - toolOverrides: {}, - enableSideBySide: false, - sendFinalMetadata: false, - isReasoning: false, - disableTextFollowUps: true, - disableMemory: true, - forceSideBySide: false, - isAsyncChat: false, - disableSelfHarmShortCircuit: false, - }), - }); - - if (response.ok) { - return { valid: true, error: null }; + let response; + try { + response = await tlsFetchGrok("https://grok.com/rest/app-chat/conversations/new", { + method: "POST", + headers: applyCustomUserAgent( + { + Accept: "*/*", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + Baggage: + "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c", + "Cache-Control": "no-cache", + "Content-Type": "application/json", + Cookie: buildGrokCookieHeader(apiKey), + Origin: "https://grok.com", + Pragma: "no-cache", + Referer: "https://grok.com/", + "Sec-Ch-Ua": '"Google Chrome";v="147", "Chromium";v="147", "Not(A:Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "x-statsig-id": btoa(statsigMsg), + "x-xai-request-id": crypto.randomUUID(), + traceparent: `00-${traceId}-${spanId}-00`, + }, + providerSpecificData + ), + body: JSON.stringify({ + temporary: true, + modeId: "fast", + message: "test", + fileAttachments: [], + imageAttachments: [], + disableSearch: true, + enableImageGeneration: false, + returnImageBytes: false, + returnRawGrokInXaiRequest: false, + enableImageStreaming: false, + imageGenerationCount: 0, + forceConcise: true, + toolOverrides: {}, + enableSideBySide: false, + sendFinalMetadata: false, + isReasoning: false, + disableTextFollowUps: true, + disableMemory: true, + forceSideBySide: false, + isAsyncChat: false, + disableSelfHarmShortCircuit: false, + }), + timeoutMs: 15_000, + }); + } catch (err: any) { + if (err instanceof TlsClientUnavailableError) { + return { + valid: false, + error: `TLS impersonation client unavailable: ${err.message}`, + }; + } + throw err; } let errorDetail = ""; try { - errorDetail = (await response.text()).slice(0, 240); + errorDetail = (response.text || "").slice(0, 240); } catch {} + // Detect Cloudflare challenge pages even with a 200 status from tls-client-node + if (isCloudflareChallenge(errorDetail)) { + return { + valid: false, + error: + "Grok validation blocked by Cloudflare anti-bot. Try a residential IP or proxy.", + }; + } + + if (response.status >= 200 && response.status < 300) { + return { valid: true, error: null }; + } + if (response.status === 401) { return { valid: false, diff --git a/tests/unit/grok-web.test.ts b/tests/unit/grok-web.test.ts index 0cbd45a15e..e87d9c74a5 100644 --- a/tests/unit/grok-web.test.ts +++ b/tests/unit/grok-web.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/grokTlsClient.ts"; const { GrokWebExecutor } = await import("../../open-sse/executors/grok-web.ts"); const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); @@ -18,34 +19,45 @@ function mockGrokStream(events: unknown[]) { } function mockFetch(status: number, events: unknown[]) { - const original = globalThis.fetch; - globalThis.fetch = async () => - new Response(mockGrokStream(events), { + __setTlsFetchOverrideForTesting(async () => { + if (status >= 400) { + return { + status, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify(events), + body: null, + }; + } + return { status, - headers: { "Content-Type": "application/json" }, - }); + headers: new Headers({ "Content-Type": "application/x-ndjson" }), + text: null, + body: mockGrokStream(events), + }; + }); return () => { - globalThis.fetch = original; + __setTlsFetchOverrideForTesting(null); }; } function mockFetchCapture(events: unknown[]) { - const original = globalThis.fetch; let capturedUrl: string | null = null; let capturedHeaders: Record = {}; let capturedBody: Record = {}; - globalThis.fetch = async (url: any, opts: any) => { - capturedUrl = String(url); - capturedHeaders = opts?.headers || {}; - capturedBody = JSON.parse(opts?.body || "{}"); - return new Response(mockGrokStream(events), { + __setTlsFetchOverrideForTesting(async (url, options) => { + capturedUrl = url; + capturedHeaders = options.headers || {}; + capturedBody = JSON.parse(options.body || "{}"); + return { status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; + headers: new Headers({ "Content-Type": "application/x-ndjson" }), + text: null, + body: mockGrokStream(events), + }; + }); return { restore: () => { - globalThis.fetch = original; + __setTlsFetchOverrideForTesting(null); }, get url() { return capturedUrl; @@ -65,6 +77,10 @@ const SIMPLE_RESPONSE = [ { result: { response: { modelResponse: { message: "Hello world!", responseId: "resp-123" } } } }, ]; +test.afterEach(() => { + __setTlsFetchOverrideForTesting(null); +}); + // ─── Registration ─────────────────────────────────────────────────────────── test("GrokWebExecutor is registered in executor index", () => { @@ -1077,14 +1093,15 @@ test("Non-streaming: skips unmapped tool_usage_card text structurally", async () test("Request: forwards tool results into Grok prompt for the next turn", async () => { let capturedBody = ""; - const originalFetch = global.fetch; - global.fetch = async (_url: string | URL | Request, init?: RequestInit) => { - capturedBody = String(init?.body || ""); - return new Response(mockGrokStream([{ result: { response: { modelResponse: { message: "It is sunny." } } } }]), { + __setTlsFetchOverrideForTesting(async (_url, options) => { + capturedBody = String(options.body || ""); + return { status: 200, - headers: { "Content-Type": "application/json" }, - }); - }; + headers: new Headers({ "Content-Type": "application/json" }), + text: null, + body: mockGrokStream([{ result: { response: { modelResponse: { message: "It is sunny." } } } }]), + }; + }); try { const executor = new GrokWebExecutor(); await executor.execute({ @@ -1114,7 +1131,7 @@ test("Request: forwards tool results into Grok prompt for the next turn", async assert.ok(payload.message.includes("do not call the same tool again")); assert.ok(payload.message.includes("sunny")); } finally { - global.fetch = originalFetch; + __setTlsFetchOverrideForTesting(null); } }); diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index d30be44f0c..235326650d 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -10,11 +10,15 @@ const { const { __setTlsFetchOverrideForTesting: __setPplxTlsFetchOverride } = await import("../../open-sse/services/perplexityTlsClient.ts"); +const { __setTlsFetchOverrideForTesting: __setGrokTlsFetchOverride } = + await import("../../open-sse/services/grokTlsClient.ts"); + const originalFetch = globalThis.fetch; test.afterEach(() => { globalThis.fetch = originalFetch; __setPplxTlsFetchOverride(null); + __setGrokTlsFetchOverride(null); }); function toPlainHeaders(headers: any) { @@ -255,6 +259,13 @@ test("gitlab specialty validator treats 401 as invalid PAT", async () => { test("web-cookie provider validators accept valid Grok, Perplexity, Blackbox and Muse Spark session cookies", async () => { const calls = []; + // Grok now uses tlsFetchGrok (TLS-impersonating client) to bypass Cloudflare Enterprise. + let grokTlsCall: { url: string; options: Record } | null = null; + __setGrokTlsFetchOverride(async (url, options) => { + grokTlsCall = { url, options }; + return { status: 200, headers: new Headers(), text: null, body: null }; + }); + // Perplexity now uses tlsFetchPerplexity (TLS-impersonating client) instead of globalThis.fetch // to bypass Cloudflare Enterprise. Use the test-only override hook to intercept calls. let pplxTlsCall: { url: string; options: Record } | null = null; @@ -267,9 +278,6 @@ test("web-cookie provider validators accept valid Grok, Perplexity, Blackbox and const target = String(url); calls.push({ url: target, init }); - if (target.includes("grok.com/rest/app-chat/conversations/new")) { - return new Response(JSON.stringify({ ok: true }), { status: 200 }); - } if (target.includes("app.blackbox.ai/api/auth/session")) { return new Response( JSON.stringify({ @@ -317,9 +325,6 @@ test("web-cookie provider validators accept valid Grok, Perplexity, Blackbox and assert.equal(blackbox.valid, true); assert.equal(museSpark.valid, true); - const grokCall = calls.find((call) => - call.url.includes("grok.com/rest/app-chat/conversations/new") - ); const blackboxSessionCall = calls.find((call) => call.url.includes("app.blackbox.ai/api/auth/session") ); @@ -328,8 +333,14 @@ test("web-cookie provider validators accept valid Grok, Perplexity, Blackbox and ); const museSparkCall = calls.find((call) => call.url.includes("meta.ai/api/graphql")); - assert.equal(grokCall?.init.headers.Cookie, "sso=grok-cookie"); - const grokBody = JSON.parse(String(grokCall?.init.body || "{}")); + // Grok goes through tlsFetchGrok (TLS override), not globalThis.fetch. + assert.ok(grokTlsCall, "grok TLS override was called"); + assert.ok(grokTlsCall!.url.includes("grok.com/rest/app-chat/conversations/new")); + assert.equal( + (grokTlsCall!.options.headers as Record)["Cookie"], + "sso=grok-cookie" + ); + const grokBody = JSON.parse(String(grokTlsCall!.options.body || "{}")); assert.equal(grokBody.modeId, "fast"); assert.equal("modelName" in grokBody, false); assert.equal("modelMode" in grokBody, false); @@ -422,14 +433,10 @@ test("web-cookie provider validators surface auth and subscription failures", as test("grok-web validator: full DevTools cookie blob is parsed for the sso value", async () => { let capturedCookie = ""; - globalThis.fetch = async (url, init = {}) => { - const target = String(url); - if (target.includes("grok.com/rest/app-chat/conversations/new")) { - capturedCookie = ((init.headers as Record) || {}).Cookie || ""; - return new Response(JSON.stringify({ result: { conversation: {} } }), { status: 200 }); - } - throw new Error(`unexpected fetch: ${target}`); - }; + __setGrokTlsFetchOverride(async (_url, options) => { + capturedCookie = ((options.headers as Record) || {}).Cookie || ""; + return { status: 200, headers: new Headers(), text: null, body: null }; + }); const blob = "i18nextLng=en; stblid=foo; __cf_bm=bar; sso=eyJTARGET.abc.def; cf_clearance=baz;"; const result = await validateProviderApiKey({ provider: "grok-web", apiKey: blob }); @@ -439,9 +446,9 @@ test("grok-web validator: full DevTools cookie blob is parsed for the sso value" }); test("grok-web validator: empty/missing sso in input returns 'Missing sso cookie'", async () => { - globalThis.fetch = async () => { + __setGrokTlsFetchOverride(async () => { throw new Error("validator should short-circuit before fetching"); - }; + }); const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "foo=1; bar=2;", @@ -451,16 +458,14 @@ test("grok-web validator: empty/missing sso in input returns 'Missing sso cookie }); test("grok-web validator: non-auth 403 is reported as failure with upstream body, not silently passed", async () => { - globalThis.fetch = async (url) => { - const target = String(url); - if (target.includes("grok.com/rest/app-chat/conversations/new")) { - return new Response( - JSON.stringify({ error: { code: 7, message: "Model is not found", details: [] } }), - { status: 403 } - ); - } - throw new Error(`unexpected fetch: ${target}`); - }; + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ error: { code: 7, message: "Model is not found", details: [] } }), + body: null, + }; + }); const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "good-cookie" }); assert.equal(result.valid, false); @@ -469,13 +474,9 @@ test("grok-web validator: non-auth 403 is reported as failure with upstream body }); test("grok-web validator: generic 403 forbidden is rejected, not silently passed", async () => { - globalThis.fetch = async (url) => { - const target = String(url); - if (target.includes("grok.com/rest/app-chat/conversations/new")) { - return new Response("Forbidden", { status: 403 }); - } - throw new Error(`unexpected fetch: ${target}`); - }; + __setGrokTlsFetchOverride(async () => { + return { status: 403, headers: new Headers(), text: "Forbidden", body: null }; + }); const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "any-cookie" }); assert.equal(result.valid, false); @@ -483,28 +484,55 @@ test("grok-web validator: generic 403 forbidden is rejected, not silently passed }); test("grok-web validator: 403 with credential-rejection body is treated as auth-failed", async () => { - globalThis.fetch = async (url) => { - const target = String(url); - if (target.includes("grok.com/rest/app-chat/conversations/new")) { - return new Response( - JSON.stringify({ - error: { - code: 16, - message: "Failed to look up session ID. [WKE=unauthenticated:invalid-credentials]", - details: [], - }, - }), - { status: 403 } - ); - } - throw new Error(`unexpected fetch: ${target}`); - }; + __setGrokTlsFetchOverride(async () => { + return { + status: 403, + headers: new Headers(), + text: JSON.stringify({ + error: { + code: 16, + message: "Failed to look up session ID. [WKE=unauthenticated:invalid-credentials]", + details: [], + }, + }), + body: null, + }; + }); const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "bad-cookie" }); assert.equal(result.valid, false); assert.match(result.error || "", /Invalid SSO cookie/i); }); +test("grok-web validator: TLS client unavailable surfaces actionable error", async () => { + __setGrokTlsFetchOverride(async () => { + const { TlsClientUnavailableError } = await import( + "../../open-sse/services/grokTlsClient.ts" + ); + throw new TlsClientUnavailableError("native binary not found"); + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "sso=abc" }); + assert.equal(result.valid, false); + assert.match(result.error || "", /TLS impersonation client unavailable/i); + assert.match(result.error || "", /native binary not found/i); +}); + +test("grok-web validator: Cloudflare challenge page is detected and reported", async () => { + __setGrokTlsFetchOverride(async () => { + return { + status: 200, + headers: new Headers(), + text: "Just a moment...", + body: null, + }; + }); + + const result = await validateProviderApiKey({ provider: "grok-web", apiKey: "sso=abc" }); + assert.equal(result.valid, false); + assert.match(result.error || "", /Cloudflare anti-bot/i); +}); + // ─── chatgpt-web validator ────────────────────────────────────────────────── // Mocks the TLS-impersonating fetch so unit tests don't need the native binding.