From 606aa9a7b091e307bcd64e88d8ea1656be7caa83 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:43:33 -0300 Subject: [PATCH] fix(providers): honor configured proxy on Grok Build egress (#7244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): honor configured proxy on Grok Build egress The grok-cli executor reaches Grok Build over raw `https.request()` (forced IPv4, to dodge Cloudflare blocking on the direct path) rather than the process-wide patched `fetch()` that every other executor uses. `https.request()` never consults the proxy AsyncLocalStorage context, so the proxy the caller already pinned upstream in chatHelpers.ts (`runWithProxyContext`) was silently ignored on BOTH grok-cli paths: chat inference (`nativePost`) and OAuth token refresh (`nativeHttpsPost`, POST https://auth.x.ai/oauth2/token). User-visible effect: an operator who assigns a proxy to a Grok Build connection (or provider/global scope) still egresses on the host's real IP — an IP leak that defeats account-isolation/anonymity setups, and breaks Grok Build entirely for operators who must egress through a proxy. Fix is delta-only: `resolveGrokRequestDispatch()` reads the already-resolved proxy via the shared `resolveProxyForRequest()` and returns either an HttpsProxyAgent bound to it, or — when no proxy is configured — the existing forced-IPv4 direct options, unchanged. Only HTTP/HTTPS CONNECT proxies are supported on this path; an explicitly configured proxy of another kind (SOCKS5) fails closed rather than silently leaking direct, matching the fail-closed convention for OAuth/account proxies (#3051). The proxy URL is never logged, so proxy credentials cannot leak into logs. Regression test: tests/unit/grok-cli-proxy-selection.test.ts (RED before the fix — `resolveGrokRequestDispatch` did not exist and both request builders hardcoded `family: 4` with no agent; GREEN after). Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/2343 * chore(changelog): fragment for #7244 --------- Co-authored-by: ryanngit <74137224+ryanngit@users.noreply.github.com> --- .../fixes/7244-grok-cli-honor-proxy.md | 1 + open-sse/executors/grok-cli.ts | 59 ++++++++++++++++++- tests/unit/grok-cli-proxy-selection.test.ts | 56 ++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/7244-grok-cli-honor-proxy.md create mode 100644 tests/unit/grok-cli-proxy-selection.test.ts diff --git a/changelog.d/fixes/7244-grok-cli-honor-proxy.md b/changelog.d/fixes/7244-grok-cli-honor-proxy.md new file mode 100644 index 0000000000..6a83400d73 --- /dev/null +++ b/changelog.d/fixes/7244-grok-cli-honor-proxy.md @@ -0,0 +1 @@ +- **fix(providers):** honor a configured proxy on Grok Build egress — the grok-cli executor used raw `https.request()` and bypassed the proxy context, leaking the host IP on chat inference and OAuth token refresh. (thanks @ryanngit) diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index ced0ac1465..ef4f9eb5ec 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -2,7 +2,8 @@ * GrokCliExecutor — Grok Build Provider * * Routes requests through Grok's chat proxy endpoint using OAuth authentication. - * Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking. + * Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking + * (only for the no-proxy direct path — see resolveGrokRequestDispatch below). * Supports automatic token refresh via refresh_token. */ @@ -14,11 +15,59 @@ import { } from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { resolvePublicCred } from "../utils/publicCreds.ts"; +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; import https from "node:https"; +import { HttpsProxyAgent } from "https-proxy-agent"; const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token"; const REQUEST_TIMEOUT_MS = 60_000; +type ProxyResolution = { source: string; proxyUrl: string | null }; +type GrokRequestDispatch = { agent?: https.Agent; family?: 4 }; + +/** + * Resolve how a Grok Build request to `targetUrl` should egress: through the + * operator's configured proxy (connection/provider/global — whatever the caller + * already pinned via `runWithProxyContext` upstream in chatHelpers.ts) when one + * is set, or direct with the existing forced-IPv4 workaround when none is. + * + * This executor talks to Grok via raw `https.request()` instead of the global + * patched `fetch()` (every other executor's path), so it never consulted the + * proxy context at all — a configured proxy was silently ignored and the + * request always egressed on the host's real IP. Only HTTP/HTTPS (CONNECT) + * proxies are supported here; an explicitly configured proxy of another kind + * (e.g. SOCKS5) fails closed rather than silently falling back to direct, + * matching the "fail closed for OAuth usage account proxies" convention (#3051). + * + * `resolveProxy` is injectable for tests; defaults to the shared + * `resolveProxyForRequest` used by the patched global fetch. + */ +export function resolveGrokRequestDispatch( + targetUrl: string, + resolveProxy: (url: string) => ProxyResolution = resolveProxyForRequest +): GrokRequestDispatch { + const { proxyUrl } = resolveProxy(targetUrl); + + if (!proxyUrl) { + return { family: 4 }; + } + + let protocol: string; + try { + protocol = new URL(proxyUrl).protocol; + } catch { + throw new Error("Grok Build: configured proxy URL could not be parsed"); + } + + if (protocol === "http:" || protocol === "https:") { + return { agent: new HttpsProxyAgent(proxyUrl) as unknown as https.Agent }; + } + + throw new Error( + "Grok Build: configured proxy protocol is not supported for this provider (HTTP/HTTPS proxies only)" + ); +} + export class GrokCliExecutor extends BaseExecutor { constructor() { super("grok-cli", PROVIDERS["grok-cli"]); @@ -100,6 +149,7 @@ export class GrokCliExecutor extends BaseExecutor { timeoutMs = 10_000 ): Promise<{ status: number; body: string }> { const urlObj = new URL(url); + const dispatch = resolveGrokRequestDispatch(url); return new Promise((resolve, reject) => { const timer = setTimeout(() => req.destroy(new Error("Timeout")), timeoutMs); @@ -110,7 +160,8 @@ export class GrokCliExecutor extends BaseExecutor { port: 443, path: urlObj.pathname + urlObj.search, method: "POST", - family: 4, + ...(dispatch.family ? { family: dispatch.family } : {}), + ...(dispatch.agent ? { agent: dispatch.agent } : {}), headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr), @@ -145,6 +196,7 @@ export class GrokCliExecutor extends BaseExecutor { signal?: AbortSignal | null ): Promise { const urlObj = new URL(url); + const dispatch = resolveGrokRequestDispatch(url); if (signal?.aborted) { return Promise.reject(new Error("Aborted")); @@ -167,7 +219,8 @@ export class GrokCliExecutor extends BaseExecutor { port: 443, path: urlObj.pathname + urlObj.search, method: "POST", - family: 4, + ...(dispatch.family ? { family: dispatch.family } : {}), + ...(dispatch.agent ? { agent: dispatch.agent } : {}), headers: { ...headers, "Content-Length": Buffer.byteLength(bodyStr), diff --git a/tests/unit/grok-cli-proxy-selection.test.ts b/tests/unit/grok-cli-proxy-selection.test.ts new file mode 100644 index 0000000000..764ed364c2 --- /dev/null +++ b/tests/unit/grok-cli-proxy-selection.test.ts @@ -0,0 +1,56 @@ +// Regression test for: Grok Build (grok-cli) inference/refresh requests bypassed the +// operator's configured proxy entirely. The executor talks to Grok's upstream via raw +// Node `https.request()` (forced IPv4, to dodge Cloudflare blocking on the direct path) +// instead of the process-wide patched `fetch()` that every other executor uses — so a +// proxy pinned to the connection/provider/global scope was silently ignored, leaking the +// real egress IP and defeating account-isolation/anonymity setups. Mirrors the class of +// bug fixed upstream in decolua/9router#2343 ("fix(oauth): honor proxy selection during +// OAuth login"), adapted to OmniRoute's actual grok-cli architecture (import-token flow, +// no device-code polling) where the leak lives in `resolveGrokRequestDispatch()`. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveGrokRequestDispatch } from "../../open-sse/executors/grok-cli.ts"; + +const TARGET_URL = "https://grok.x.ai/rest/app-chat/conversations/new"; + +test("resolveGrokRequestDispatch: no proxy configured -> direct IPv4 dispatch (unchanged behavior)", () => { + const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({ + source: "direct", + proxyUrl: null, + })); + + assert.equal(dispatch.family, 4); + assert.equal(dispatch.agent, undefined); +}); + +test("resolveGrokRequestDispatch: HTTP proxy configured -> request is dispatched through a proxy agent, not direct", () => { + const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({ + source: "context", + proxyUrl: "http://proxy.internal:8080", + })); + + // The fix: an agent bound to the configured proxy must be present, and the + // direct-IPv4 workaround must NOT be applied (it would race the proxy tunnel). + assert.ok(dispatch.agent, "expected a proxy agent to be constructed"); + assert.notEqual(dispatch.family, 4); +}); + +test("resolveGrokRequestDispatch: HTTPS proxy configured -> request is dispatched through a proxy agent", () => { + const dispatch = resolveGrokRequestDispatch(TARGET_URL, () => ({ + source: "context", + proxyUrl: "https://user:pass@proxy.internal:8443", + })); + + assert.ok(dispatch.agent, "expected a proxy agent to be constructed"); +}); + +test("resolveGrokRequestDispatch: unsupported proxy protocol (socks5) fails closed instead of leaking direct", () => { + assert.throws( + () => + resolveGrokRequestDispatch(TARGET_URL, () => ({ + source: "context", + proxyUrl: "socks5://proxy.internal:1080", + })), + /proxy/i + ); +});