diff --git a/src/agents/tool-images.input-cap.test.ts b/src/agents/tool-images.input-cap.test.ts new file mode 100644 index 000000000000..6a526933af47 --- /dev/null +++ b/src/agents/tool-images.input-cap.test.ts @@ -0,0 +1,25 @@ +// Tool image input-cap tests verify pathological oversized base64 input is +// rejected before Buffer.from allocates a transient multi-MB buffer. +import { describe, expect, it } from "vitest"; +import { sanitizeContentBlocksImages } from "./tool-images.js"; + +const MAX_IMAGE_INPUT_BYTES = 10 * 1024 * 1024; + +describe("tool image sanitizer oversized input cap", () => { + it("rejects oversized estimated input before decode allocation", async () => { + const encodedLength = Math.ceil(((MAX_IMAGE_INPUT_BYTES + 1) * 4) / 3 / 4) * 4; + const oversizedBase64 = "A".repeat(encodedLength); + + const out = await sanitizeContentBlocksImages( + [{ type: "image" as const, data: oversizedBase64, mimeType: "image/png" }], + "test", + ); + + expect(out).toStrictEqual([ + { + type: "text", + text: "[test] omitted image payload: image exceeds input size limit (10.00MB)", + }, + ]); + }); +}); diff --git a/src/agents/tool-images.ts b/src/agents/tool-images.ts index 0550c3a55348..7fc219a495d1 100644 --- a/src/agents/tool-images.ts +++ b/src/agents/tool-images.ts @@ -3,7 +3,7 @@ * * Downscales and recompresses oversized base64 image blocks before provider replay. */ -import { canonicalizeBase64 } from "@openclaw/media-core/base64"; +import { canonicalizeBase64, estimateBase64DecodedBytes } from "@openclaw/media-core/base64"; import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { toErrorObject } from "../infra/errors.js"; import type { ImageContent } from "../llm/types.js"; @@ -33,6 +33,11 @@ type TextContentBlock = Extract; // tool outputs do not break later turns or silent channel replies. const MAX_IMAGE_DIMENSION_PX = DEFAULT_IMAGE_MAX_DIMENSION_PX; const MAX_IMAGE_BYTES = DEFAULT_IMAGE_MAX_BYTES; +// Hard cap on decoded input bytes before Buffer.from/resizer allocation. A +// conservative limit well below demonstrated OOM thresholds, leaving headroom +// for canonicalization, decode, and image-processing allocations while still +// permitting legitimate tool-output images. +const MAX_IMAGE_INPUT_BYTES = 10 * 1024 * 1024; const log = createSubsystemLogger("agents/tool-images"); function isImageTypeBlock(block: unknown): block is Record & { type: "image" } { @@ -327,6 +332,19 @@ export async function sanitizeContentBlocksImages( continue; } + // Estimate decoded bytes on the raw payload before trim/canonicalize/decode + // so pathological multi-GB base64 cannot force a transient large allocation. + // maxBytes is the post-decode resize target; MAX_IMAGE_INPUT_BYTES is a + // conservative pre-decode ceiling (10 MiB) far below the 25MP/100MB + // processing headroom, so legitimate tool images still decode and resize. + if (estimateBase64DecodedBytes(block.data) > MAX_IMAGE_INPUT_BYTES) { + out.push({ + type: "text", + text: `[${label}] omitted image payload: image exceeds input size limit (${formatBytesShort(MAX_IMAGE_INPUT_BYTES)})`, + } satisfies TextContentBlock); + continue; + } + const data = block.data.trim(); if (!data) { out.push({