From 4139d7dd3e534fbe38e5895d6698e775857e914c Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 23 Aug 2026 10:32:26 +0900 Subject: [PATCH] fix(web-search): bound xAI error response bodies --- src/web-search/xai-executor.ts | 18 ++++++++++++++---- tests/xai-web-search.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/web-search/xai-executor.ts b/src/web-search/xai-executor.ts index 5edc6686f0..00c164eb70 100644 --- a/src/web-search/xai-executor.ts +++ b/src/web-search/xai-executor.ts @@ -14,6 +14,7 @@ import type { OcxProviderConfig } from "../types"; import { getValidAccessToken, publicOAuthAuthenticationErrorMessage } from "../oauth"; import { fetchWithResetRetry } from "../lib/upstream-retry"; import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { redactSecretString } from "../lib/redact"; import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse"; @@ -114,10 +115,19 @@ export async function runXaiWebSearch( ); const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); if (!res.ok) { - const t = await res.text().catch(() => ""); - detachBodyGuard(); - const entitlement = res.status === 401 || res.status === 403 ? " (Grok OAuth entitlement — re-run ocx login xai?)" : ""; - return { text: "", sources: [], error: `xai sidecar HTTP ${res.status}${entitlement}: ${redactSecretString(t.slice(0, 200))}` }; + try { + const bounded = await readBoundedResponseBytes(res, { + maxBytes: MAX_SIDECAR_RESPONSE_BYTES, + signal: linkedSignal.signal, + }); + const detail = bounded.oversized + ? "response body exceeded byte bound" + : redactSecretString(new TextDecoder().decode(bounded.bytes).slice(0, 200)); + const entitlement = res.status === 401 || res.status === 403 ? " (Grok OAuth entitlement — re-run ocx login xai?)" : ""; + return { text: "", sources: [], error: `xai sidecar HTTP ${res.status}${entitlement}: ${detail}` }; + } finally { + detachBodyGuard(); + } } try { return await parseXaiResponsesSSE(res); diff --git a/tests/xai-web-search.test.ts b/tests/xai-web-search.test.ts index 41e4a42c90..5d70d17868 100644 --- a/tests/xai-web-search.test.ts +++ b/tests/xai-web-search.test.ts @@ -157,6 +157,34 @@ describe("credential pinning + loop fail-closed (review blockers)", () => { globalThis.fetch = realFetch; } }); + + test("non-OK response bodies are byte-bounded and canceled upstream", async () => { + let producedBytes = 0; + let canceled = false; + const chunk = new Uint8Array(1024).fill(0x61); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(controller) { + producedBytes += chunk.byteLength; + controller.enqueue(chunk); + }, + cancel() { + canceled = true; + }, + }), { status: 500 })) as typeof fetch; + try { + const { runXaiWebSearch } = await import("../src/web-search/xai-executor"); + const out = await runXaiWebSearch("q", "xai", xaiProvider, { model: "grok-4.6", reasoning: "low", timeoutMs: 5000, describeImages: false }); + + expect(out.error).toContain("response body exceeded byte bound"); + // The stream implementation may prefetch a small number of chunks, but it must + // stop near the cap rather than consume an arbitrarily large upstream body. + expect(producedBytes).toBeLessThanOrEqual(MAX_SIDECAR_RESPONSE_BYTES + (4 * chunk.byteLength)); + expect(canceled).toBe(true); + } finally { + globalThis.fetch = realFetch; + } + }); }); import { runWithWebSearch, type WebSearchLoopDeps } from "../src/web-search/loop";