Skip to content

Commit eedb29d

Browse files
committed
fix(agents): bound Google prompt cache response reads
The Google embedded-agent prompt-cache helpers parsed cachedContents metadata with an unbounded `await response.json()` in both createGooglePromptCache and updateGooglePromptCacheTtl. A buggy or hostile Generative Language endpoint returning a 200 with a large or never-ending body (especially with no Content-Length) would be fully buffered into memory before parsing, with the existing cancelUnreadResponseBody guard firing too late (json() already drained the body). Route both reads through the shared streaming byte-cap reader (readResponseWithLimit) under a 1 MiB cap, cancelling the stream on overflow instead of buffering it, then JSON.parse the bounded buffer. This is the symmetric Google-endpoint counterpart to the Anthropic error-stream and gateway pricing-catalog bounds. Adds regressions that stream an oversized no-Content-Length body through the real create and TTL-refresh paths and assert the body is cancelled.
1 parent 7217477 commit eedb29d

2 files changed

Lines changed: 130 additions & 2 deletions

File tree

src/agents/embedded-agent-runner/google-prompt-cache.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ function createCacheFetchMock(params: { name: string; expireTime: string }) {
6868
);
6969
}
7070

71+
// Builds a 200-OK response whose body streams more than 1 MiB with no
72+
// Content-Length, mirroring a buggy/hostile Google cachedContents endpoint.
73+
// The shared byte-cap reader must cancel the body before fully buffering it.
74+
function createOversizedJsonResponse(): { response: Response; cancel: ReturnType<typeof vi.fn> } {
75+
const cancel = vi.fn(async () => undefined);
76+
let pullCount = 0;
77+
const response = new Response(
78+
new ReadableStream<Uint8Array>({
79+
pull(controller) {
80+
pullCount += 1;
81+
// First chunk already exceeds the 1 MiB cap so the reader truncates
82+
// and cancels instead of waiting for the (never-ending) rest.
83+
controller.enqueue(new Uint8Array(pullCount === 1 ? 1024 * 1024 + 1 : 1));
84+
},
85+
cancel,
86+
}),
87+
{
88+
status: 200,
89+
headers: { "content-type": "application/json" },
90+
},
91+
);
92+
return { response, cancel };
93+
}
94+
7195
function createCapturingStreamFn(result = "stream") {
7296
// The wrapper mutates payloads through onPayload before calling the real
7397
// stream; capture that final payload instead of mocking Google responses.
@@ -552,4 +576,91 @@ describe("google prompt cache", () => {
552576
expect(wrapped).toBeUndefined();
553577
expect(fetchMock).not.toHaveBeenCalled();
554578
});
579+
580+
it("bounds an oversized cache-creation response body instead of buffering it", async () => {
581+
const now = 4_000_000;
582+
const { response, cancel } = createOversizedJsonResponse();
583+
const fetchMock = vi.fn(async () => response);
584+
const sessionManager = makeSessionManager();
585+
const innerStreamFn = vi.fn(() => "stream" as never);
586+
587+
const wrapped = await preparePromptCacheStream({
588+
fetchMock,
589+
now,
590+
sessionManager,
591+
streamFn: innerStreamFn,
592+
});
593+
594+
await expect(
595+
Promise.resolve(
596+
wrapped?.(
597+
makeGoogleModel(),
598+
{ systemPrompt: "Follow policy.", messages: [] } as never,
599+
{} as never,
600+
),
601+
),
602+
).rejects.toThrow(/Google prompt cache response too large: \d+ bytes/);
603+
604+
expect(fetchMock).toHaveBeenCalledTimes(1);
605+
expect(callArg(fetchMock, 0, 0)).toBe(
606+
"https://generativelanguage.googleapis.com/v1beta/cachedContents",
607+
);
608+
expect(cancel).toHaveBeenCalledOnce();
609+
});
610+
611+
it("bounds an oversized cache-refresh response body instead of buffering it", async () => {
612+
const now = 4_500_000;
613+
const expireSoon = new Date(now + 60_000).toISOString();
614+
const systemPromptDigest = crypto.createHash("sha256").update("Follow policy.").digest("hex");
615+
const sessionManager = makeSessionManager([
616+
{
617+
id: "entry-1",
618+
parentId: null,
619+
timestamp: new Date(now - 5_000).toISOString(),
620+
type: "custom",
621+
customType: "openclaw.google-prompt-cache",
622+
data: {
623+
status: "ready",
624+
timestamp: now - 5_000,
625+
provider: "google",
626+
modelId: "gemini-3.1-pro-preview",
627+
modelApi: "google-generative-ai",
628+
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
629+
systemPromptDigest,
630+
cacheRetention: "long",
631+
cachedContent: "cachedContents/system-cache-overflow",
632+
expireTime: expireSoon,
633+
},
634+
},
635+
]);
636+
const { response, cancel } = createOversizedJsonResponse();
637+
const fetchMock = vi.fn(async () => response);
638+
const { streamFn: innerStreamFn, getCapturedPayload } = createCapturingStreamFn();
639+
640+
const wrapped = await preparePromptCacheStream({
641+
fetchMock,
642+
now,
643+
sessionManager,
644+
streamFn: innerStreamFn,
645+
});
646+
647+
// The TTL-refresh read swallows errors (.catch(() => null)) and falls back
648+
// to the still-valid cached content, so the oversized body must be cancelled
649+
// by the byte cap rather than fully buffered.
650+
await Promise.resolve(
651+
wrapped?.(
652+
makeGoogleModel(),
653+
{ systemPrompt: "Follow policy.", messages: [] } as never,
654+
{} as never,
655+
),
656+
);
657+
658+
expect(fetchMock).toHaveBeenCalledTimes(1);
659+
expect(fetchUrl(fetchMock)).toBe(
660+
"https://generativelanguage.googleapis.com/v1beta/cachedContents/system-cache-overflow?updateMask=ttl",
661+
);
662+
expect(fetchInit(fetchMock).method).toBe("PATCH");
663+
expect(cancel).toHaveBeenCalledOnce();
664+
expect(getCapturedPayload()?.cachedContent).toBe("cachedContents/system-cache-overflow");
665+
});
555666
});

src/agents/embedded-agent-runner/google-prompt-cache.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Prepares Google prompt-cache payloads for embedded-agent stream calls.
33
*/
44
import crypto from "node:crypto";
5+
import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit";
56
import {
67
asDateTimestampMs,
78
isFutureDateTimestampMs,
@@ -23,6 +24,9 @@ import { isGooglePromptCacheEligible, resolveCacheRetention } from "./prompt-cac
2324
import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js";
2425

2526
const GOOGLE_PROMPT_CACHE_CUSTOM_TYPE = "openclaw.google-prompt-cache";
27+
// CachedContent metadata responses are tiny (name + expireTime); cap the read so
28+
// a buggy/hostile Google endpoint cannot stream an unbounded body into memory.
29+
const GOOGLE_PROMPT_CACHE_RESPONSE_MAX_BYTES = 1024 * 1024;
2630
const GOOGLE_PROMPT_CACHE_RETRY_BACKOFF_MS = 10 * 60_000;
2731
const GOOGLE_PROMPT_CACHE_SHORT_REFRESH_WINDOW_MS = 30_000;
2832
const GOOGLE_PROMPT_CACHE_LONG_REFRESH_WINDOW_MS = 5 * 60_000;
@@ -278,6 +282,19 @@ async function cancelUnreadResponseBody(response: Response | undefined): Promise
278282
}
279283
}
280284

285+
/**
286+
* Reads a Google cachedContents JSON body under a byte cap and parses it.
287+
* Streams through the shared limiter so an oversized response is cancelled
288+
* mid-flight instead of being fully buffered by `response.json()`.
289+
*/
290+
async function readGooglePromptCacheJson<T>(response: Response): Promise<T> {
291+
const buffer = await readResponseWithLimit(response, GOOGLE_PROMPT_CACHE_RESPONSE_MAX_BYTES, {
292+
onOverflow: ({ size, maxBytes }) =>
293+
new Error(`Google prompt cache response too large: ${size} bytes (limit: ${maxBytes} bytes)`),
294+
});
295+
return JSON.parse(buffer.toString("utf8")) as T;
296+
}
297+
281298
async function updateGooglePromptCacheTtl(params: {
282299
apiKey: string;
283300
baseUrl: string;
@@ -300,7 +317,7 @@ async function updateGooglePromptCacheTtl(params: {
300317
if (!response.ok) {
301318
return null;
302319
}
303-
const json = (await response.json()) as { expireTime?: string };
320+
const json = await readGooglePromptCacheJson<{ expireTime?: string }>(response);
304321
return json;
305322
} finally {
306323
await cancelUnreadResponseBody(response);
@@ -338,7 +355,7 @@ async function createGooglePromptCache(params: {
338355
if (!response.ok) {
339356
return null;
340357
}
341-
const json = (await response.json()) as { name?: string; expireTime?: string };
358+
const json = await readGooglePromptCacheJson<{ name?: string; expireTime?: string }>(response);
342359
const cachedContent = normalizeOptionalString(json.name) ?? "";
343360
return cachedContent ? { cachedContent, expireTime: json.expireTime } : null;
344361
} finally {

0 commit comments

Comments
 (0)