Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions src/web-search/anthropic-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "../adapters/client-fin
import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
import { sidecarEnter } from "../lib/sidecar-tracker";
import { applyUpstreamRecoveryInit, fetchWithResetRetry } from "../lib/upstream-retry";
import type { WebSearchSource } from "./parse";
import {
MAX_SIDECAR_RESPONSE_BYTES,
cancelReaderWithoutWaiting,
type WebSearchSource,
} from "./parse";
import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor";

/** Hardcoded per-turn search bound handed to the server tool (mirrors the loop's maxSearches intent). */
Expand All @@ -17,6 +21,33 @@ function isRec(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}

/** Read at most `MAX_SIDECAR_RESPONSE_BYTES` of an untrusted upstream body, then stop reading. */
async function readBoundedText(res: Response): Promise<string> {
if (!res.body) return "";
const reader = res.body.getReader();
const decoder = new TextDecoder();
let out = "";
let seen = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const remaining = MAX_SIDECAR_RESPONSE_BYTES - seen;
const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining);
seen += accepted.byteLength;
out += decoder.decode(accepted, { stream: true });
if (seen >= MAX_SIDECAR_RESPONSE_BYTES) {
cancelReaderWithoutWaiting(reader, "sidecar error body byte limit reached");
break;
}
}
out += decoder.decode();
} catch {
/* a failed error-body read must not mask the HTTP status we are about to report */
}
return out;
}

/**
* Fold an Anthropic Messages SSE stream (a web_search_20250305 turn) into a WebSearchResult.
*
Expand All @@ -41,6 +72,7 @@ export async function parseAnthropicSidecarSSE(res: Response): Promise<SidecarOu
const decoder = new TextDecoder();
const reader = res.body.getReader();
let buffer = "";
let responseBytes = 0;

const handleFrame = (data: Record<string, unknown>): void => {
const type = typeof data.type === "string" ? data.type : "";
Expand Down Expand Up @@ -82,15 +114,27 @@ export async function parseAnthropicSidecarSSE(res: Response): Promise<SidecarOu
for (;;) {
const { done, value } = await reader.read();
if (done) break;
// A sidecar that never emits a frame separator would otherwise grow `buffer` without
// limit. Bound the accepted bytes exactly like the Responses sidecar parser does.
const remaining = MAX_SIDECAR_RESPONSE_BYTES - responseBytes;
const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining);
responseBytes += accepted.byteLength;
// Normalize CRLF on the ACCUMULATED buffer so a `\r\n` pair split across two network chunks
// (chunk ends in `\r`, next starts with `\n`) still collapses to `\n` (audit round-2 F2).
buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
buffer = (buffer + decoder.decode(accepted, { stream: true })).replace(/\r\n/g, "\n");
let sep: number;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const rawFrame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
processFrame(rawFrame);
}
if (responseBytes >= MAX_SIDECAR_RESPONSE_BYTES) {
// Keep the frames already folded above, drop the unterminated tail, and do not wait on
// upstream teardown.
cancelReaderWithoutWaiting(reader, "sidecar response byte limit reached");
buffer = "";
break;
}
}
// Flush the decoder and process any final unterminated frame (a stream that ends without \n\n).
buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n");
Expand Down Expand Up @@ -177,7 +221,9 @@ export async function runAnthropicWebSearch(
// (found investigating #1419).
const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
if (!res.ok) {
const t = await res.text().catch(() => "");
// Untrusted upstream error bodies are only used for an auth-failure message, so read a
// bounded prefix instead of buffering an arbitrarily large response.
const t = await readBoundedText(res);
detachBodyGuard();
console.warn(`[web-search] anthropic sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
if (res.status === 401) {
Expand Down
2 changes: 1 addition & 1 deletion src/web-search/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function fromOutputArray(output: OutputItem[], seen: Set<string>): WebSearchResu
return { text, sources };
}

function cancelReaderWithoutWaiting(
export function cancelReaderWithoutWaiting(
reader: ReadableStreamDefaultReader<Uint8Array>,
reason: string,
): void {
Expand Down
21 changes: 21 additions & 0 deletions tests/web-search/web-search-anthropic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,27 @@ describe("parseAnthropicSidecarSSE", () => {
expect(out.error).toBeDefined();
});

test("an unterminated frame cannot buffer the stream without bound", async () => {
// A sidecar that never emits a frame separator: without a cap the parser would accumulate
// the whole stream in memory before it could fold anything.
let produced = 0;
let cancelled = false;
const chunk = new TextEncoder().encode(`data: {"filler":"${"x".repeat(64 * 1024)}"}`);
const body = new ReadableStream<Uint8Array>({
pull(c) {
if (produced > 8 * 1024 * 1024) { c.close(); return; }
produced += chunk.byteLength;
c.enqueue(chunk);
},
cancel() { cancelled = true; },
});
const out = await parseAnthropicSidecarSSE(new Response(body, { status: 200 }));
expect(cancelled).toBe(true);
// The cap stops the read long before the producer would have finished on its own.
expect(produced).toBeLessThan(1024 * 1024);
expect(out.text).toBe("");
});

test("empty results (content:[]) with answer text is a success, not an error", async () => {
const res = sseResponse([
{ type: "content_block_start", index: 0, content_block: { type: "web_search_tool_result", tool_use_id: "srvtoolu_3", content: [] } },
Expand Down
Loading