mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
grok-web: TLS fingerprint impersonation to bypass Cloudflare anti-bot (#3180); sanitize executor error bodies (#12). Integrated into release/v3.8.12. Thanks @wilsonicdev.
This commit is contained in:
@@ -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,
|
||||
|
||||
90
open-sse/services/__tests__/grokTlsClient.test.ts
Normal file
90
open-sse/services/__tests__/grokTlsClient.test.ts
Normal file
@@ -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<string, string | undefined> {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of PROXY_ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
function restoreProxyEnv(saved: Record<string, string | undefined>): 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<string, string | undefined> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
__setTlsFetchOverrideForTesting(async (url, options) => {
|
||||
observedUrl = url;
|
||||
observedOpts = options as unknown as Record<string, unknown>;
|
||||
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/");
|
||||
});
|
||||
});
|
||||
611
open-sse/services/grokTlsClient.ts
Normal file
611
open-sse/services/grokTlsClient.ts
Normal file
@@ -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<unknown> | 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<unknown> };
|
||||
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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | null | undefined
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortListener: (() => void) | null = null;
|
||||
try {
|
||||
const racers: Promise<T>[] = [
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(
|
||||
new TlsClientHangError(
|
||||
`tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
];
|
||||
if (signal) {
|
||||
racers.push(
|
||||
new Promise<T>((_, 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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const mod = await import("tls-client-node");
|
||||
const TLSClient = (mod as { TLSClient: new (opts?: Record<string, unknown>) => 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<void>;
|
||||
request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike>;
|
||||
};
|
||||
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<string, unknown>) => Promise<TlsResponseLike>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TlsResponseLike {
|
||||
status: number;
|
||||
headers: Record<string, string[]>;
|
||||
body: string; // for non-streaming requests, the full response body
|
||||
cookies?: Record<string, string>;
|
||||
text: () => Promise<string>;
|
||||
bytes: () => Promise<Uint8Array>;
|
||||
json: <T = unknown>() => Promise<T>;
|
||||
}
|
||||
|
||||
export class TlsClientUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsClientUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal | null;
|
||||
/**
|
||||
* If true, the response body is streamed to a temp file and exposed as a
|
||||
* ReadableStream<Uint8Array>. 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<Uint8Array> | 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<TlsFetchResult>) | 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<TlsFetchResult> {
|
||||
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<string, unknown> = {
|
||||
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<string, string[]>): 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<string, unknown>) => Promise<TlsResponseLike> },
|
||||
url: string,
|
||||
requestOptions: Record<string, unknown>,
|
||||
eofSymbol = "[DONE]",
|
||||
signal: AbortSignal | null = null,
|
||||
hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS
|
||||
): Promise<TlsFetchResult> {
|
||||
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<void> {
|
||||
await unlink(path).catch(() => {});
|
||||
await rmdir(dirname(path)).catch(() => {});
|
||||
}
|
||||
|
||||
async function readFirstBytes(path: string, n: number): Promise<string> {
|
||||
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<TlsResponseLike>
|
||||
): Promise<boolean> {
|
||||
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<TlsResponseLike>,
|
||||
signal: AbortSignal | null = null
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown> } | 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<string, unknown> } | 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<string, string>)["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<string, string>) || {}).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<string, string>) || {}).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: "<html><title>Just a moment...</title><script>window._cf_chl_opt</script></html>",
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user