fix: sanitize oversized image payloads

This commit is contained in:
Peter Steinberger
2026-01-18 15:19:25 +00:00
parent 891a2cc64a
commit 9c06689569
10 changed files with 208 additions and 17 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.clawd.bot
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)

View File

@@ -26,6 +26,11 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe("format");
expect(classifyFailoverReason("bad request")).toBeNull();
expect(
classifyFailoverReason(
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
),
).toBeNull();
});
it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-embedded-helpers.js";
describe("image dimension errors", () => {
it("parses anthropic image dimension errors", () => {
const raw =
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
const parsed = parseImageDimensionError(raw);
expect(parsed).not.toBeNull();
expect(parsed?.maxDimensionPx).toBe(2000);
expect(parsed?.messageIndex).toBe(84);
expect(parsed?.contentIndex).toBe(1);
expect(isImageDimensionErrorMessage(raw)).toBe(true);
});
});

View File

@@ -23,5 +23,10 @@ describe("isCloudCodeAssistFormatError", () => {
});
it("ignores unrelated errors", () => {
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
expect(
isCloudCodeAssistFormatError(
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
),
).toBe(false);
});
});

View File

@@ -21,11 +21,13 @@ export {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
isImageDimensionErrorMessage,
isOverloadedErrorMessage,
isRawApiErrorPayload,
isRateLimitAssistantError,
isRateLimitErrorMessage,
isTimeoutErrorMessage,
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js";
export {
downgradeGeminiHistory,

View File

@@ -339,7 +339,6 @@ const ERROR_PATTERNS = {
"no api key found",
],
format: [
"invalid_request_error",
"string should match pattern",
"tool_use.id",
"tool_use_id",
@@ -348,6 +347,10 @@ const ERROR_PATTERNS = {
],
} as const;
const IMAGE_DIMENSION_ERROR_RE =
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
if (!raw) return false;
const value = raw.toLowerCase();
@@ -390,8 +393,31 @@ export function isOverloadedErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
}
export function parseImageDimensionError(raw: string): {
maxDimensionPx?: number;
messageIndex?: number;
contentIndex?: number;
raw: string;
} | null {
if (!raw) return null;
const lower = raw.toLowerCase();
if (!lower.includes("image dimensions exceed max allowed size")) return null;
const limitMatch = raw.match(IMAGE_DIMENSION_ERROR_RE);
const pathMatch = raw.match(IMAGE_DIMENSION_PATH_RE);
return {
maxDimensionPx: limitMatch?.[1] ? Number.parseInt(limitMatch[1], 10) : undefined,
messageIndex: pathMatch?.[1] ? Number.parseInt(pathMatch[1], 10) : undefined,
contentIndex: pathMatch?.[2] ? Number.parseInt(pathMatch[2], 10) : undefined,
raw,
};
}
export function isImageDimensionErrorMessage(raw: string): boolean {
return Boolean(parseImageDimensionError(raw));
}
export function isCloudCodeAssistFormatError(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
}
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
@@ -400,6 +426,7 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
}
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) return null;
if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format";

View File

@@ -31,6 +31,7 @@ import {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
parseImageDimensionError,
isRateLimitAssistantError,
isTimeoutErrorMessage,
pickFallbackThinkingLevel,
@@ -357,6 +358,26 @@ export async function runEmbeddedPiAgent(
const failoverFailure = isFailoverAssistantError(lastAssistant);
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
if (imageDimensionError && lastProfileId) {
const details = [
imageDimensionError.messageIndex !== undefined
? `message=${imageDimensionError.messageIndex}`
: null,
imageDimensionError.contentIndex !== undefined
? `content=${imageDimensionError.contentIndex}`
: null,
imageDimensionError.maxDimensionPx !== undefined
? `limit=${imageDimensionError.maxDimensionPx}px`
: null,
]
.filter(Boolean)
.join(" ");
log.warn(
`Profile ${lastProfileId} rejected image payload${details ? ` (${details})` : ""}.`,
);
}
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
const shouldRotate = (!aborted && failoverFailure) || timedOut;

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import type { ImageContent } from "@mariozechner/pi-ai";
import { assertSandboxPath } from "../../sandbox-paths.js";
import { sanitizeImageBlocks } from "../../tool-images.js";
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
import { loadWebMedia } from "../../../web/media.js";
import { resolveUserPath } from "../../../utils.js";
@@ -48,6 +49,17 @@ function isImageExtension(filePath: string): boolean {
return IMAGE_EXTENSIONS.has(ext);
}
async function sanitizeImagesWithLog(
images: ImageContent[],
label: string,
): Promise<ImageContent[]> {
const { images: sanitized, dropped } = await sanitizeImageBlocks(images, label);
if (dropped > 0) {
log.warn(`Native image: dropped ${dropped} image(s) after sanitization (${label}).`);
}
return sanitized;
}
/**
* Detects image references in a user prompt.
*
@@ -392,9 +404,18 @@ export async function detectAndLoadPromptImages(params: {
}
}
const sanitizedPromptImages = await sanitizeImagesWithLog(promptImages, "prompt:images");
const sanitizedHistoryImagesByIndex = new Map<number, ImageContent[]>();
for (const [index, images] of historyImagesByIndex) {
const sanitized = await sanitizeImagesWithLog(images, `history:images:${index}`);
if (sanitized.length > 0) {
sanitizedHistoryImagesByIndex.set(index, sanitized);
}
}
return {
images: promptImages,
historyImagesByIndex,
images: sanitizedPromptImages,
historyImagesByIndex: sanitizedHistoryImagesByIndex,
detectedRefs: allRefs,
loadedCount,
skippedCount,

View File

@@ -1,7 +1,7 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { sanitizeContentBlocksImages } from "./tool-images.js";
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js";
describe("tool image sanitizing", () => {
it("shrinks oversized images to <=5MB", async () => {
@@ -33,6 +33,56 @@ describe("tool image sanitizing", () => {
expect(image.mimeType).toBe("image/jpeg");
}, 20_000);
it("sanitizes image arrays and reports drops", async () => {
const width = 2600;
const height = 400;
const raw = Buffer.alloc(width * height * 3, 0x7f);
const png = await sharp(raw, {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
const images = [
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
];
const { images: out, dropped } = await sanitizeImageBlocks(images, "test");
expect(dropped).toBe(0);
expect(out.length).toBe(1);
const meta = await sharp(Buffer.from(out[0].data, "base64")).metadata();
expect(meta.width).toBeLessThanOrEqual(2000);
expect(meta.height).toBeLessThanOrEqual(2000);
}, 20_000);
it("shrinks images that exceed max dimension even if size is small", async () => {
const width = 2600;
const height = 400;
const raw = Buffer.alloc(width * height * 3, 0x7f);
const png = await sharp(raw, {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
const blocks = [
{
type: "image" as const,
data: png.toString("base64"),
mimeType: "image/png",
},
];
const out = await sanitizeContentBlocksImages(blocks, "test");
const image = out.find((b) => b.type === "image");
if (!image || image.type !== "image") {
throw new Error("expected image block");
}
const meta = await sharp(Buffer.from(image.data, "base64")).metadata();
expect(meta.width).toBeLessThanOrEqual(2000);
expect(meta.height).toBeLessThanOrEqual(2000);
expect(image.mimeType).toBe("image/jpeg");
}, 20_000);
it("corrects mismatched jpeg mimeType", async () => {
const jpeg = await sharp({
create: {

View File

@@ -1,5 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { createSubsystemLogger } from "../logging.js";
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
@@ -14,6 +16,7 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
// and recompress base64 image blocks when they exceed these limits.
const MAX_IMAGE_DIMENSION_PX = 2000;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const log = createSubsystemLogger("agents/tool-images");
function isImageBlock(block: unknown): block is ImageContentBlock {
if (!block || typeof block !== "object") return false;
@@ -41,26 +44,41 @@ async function resizeImageBase64IfNeeded(params: {
mimeType: string;
maxDimensionPx: number;
maxBytes: number;
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
label?: string;
}): Promise<{
base64: string;
mimeType: string;
resized: boolean;
width?: number;
height?: number;
}> {
const buf = Buffer.from(params.base64, "base64");
const meta = await getImageMetadata(buf);
const width = meta?.width;
const height = meta?.height;
const overBytes = buf.byteLength > params.maxBytes;
const maxDim = Math.max(width ?? 0, height ?? 0);
if (typeof width !== "number" || typeof height !== "number") {
if (!overBytes) {
return {
base64: params.base64,
mimeType: params.mimeType,
resized: false,
};
}
} else if (!overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
return { base64: params.base64, mimeType: params.mimeType, resized: false };
const hasDimensions = typeof width === "number" && typeof height === "number";
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
return {
base64: params.base64,
mimeType: params.mimeType,
resized: false,
width,
height,
};
}
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
log.warn("Image exceeds limits; resizing", {
label: params.label,
width,
height,
maxDimensionPx: params.maxDimensionPx,
maxBytes: params.maxBytes,
});
}
const qualities = [85, 75, 65, 55, 45, 35];
const maxDim = hasDimensions ? Math.max(width ?? 0, height ?? 0) : params.maxDimensionPx;
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
.map((v) => Math.min(params.maxDimensionPx, v))
@@ -80,10 +98,23 @@ async function resizeImageBase64IfNeeded(params: {
smallest = { buffer: out, size: out.byteLength };
}
if (out.byteLength <= params.maxBytes) {
log.info("Image resized", {
label: params.label,
width,
height,
maxDimensionPx: params.maxDimensionPx,
maxBytes: params.maxBytes,
originalBytes: buf.byteLength,
resizedBytes: out.byteLength,
quality,
side,
});
return {
base64: out.toString("base64"),
mimeType: "image/jpeg",
resized: true,
width,
height,
};
}
}
@@ -127,6 +158,7 @@ export async function sanitizeContentBlocksImages(
mimeType,
maxDimensionPx,
maxBytes,
label,
});
out.push({
...block,
@@ -144,6 +176,17 @@ export async function sanitizeContentBlocksImages(
return out;
}
export async function sanitizeImageBlocks(
images: ImageContent[],
label: string,
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
): Promise<{ images: ImageContent[]; dropped: number }> {
if (images.length === 0) return { images, dropped: 0 };
const sanitized = await sanitizeContentBlocksImages(images as ToolContentBlock[], label, opts);
const next = sanitized.filter(isImageBlock) as ImageContent[];
return { images: next, dropped: Math.max(0, images.length - next.length) };
}
export async function sanitizeToolResultImages(
result: AgentToolResult<unknown>,
label: string,