Skip to content

Commit 6b2af6c

Browse files
committed
fix(agents): keep safe tool images without native backend
1 parent 0a08625 commit 6b2af6c

2 files changed

Lines changed: 121 additions & 7 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Tool image backend-unavailable tests cover safe pass-through when native
2+
// image processing cannot load on constrained Linux/Termux platforms.
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const { backendUnavailableError, getImageMetadataMock, readImageMetadataFromHeaderMock } =
6+
vi.hoisted(() => ({
7+
backendUnavailableError: new Error("missing image backend"),
8+
getImageMetadataMock: vi.fn(),
9+
readImageMetadataFromHeaderMock: vi.fn(),
10+
}));
11+
12+
const PNG_BASE64 = "iVBORw0KGgo=";
13+
14+
async function importSanitizer() {
15+
vi.resetModules();
16+
vi.doMock("../media/media-services.js", () => ({
17+
IMAGE_REDUCE_QUALITY_STEPS: [85, 75],
18+
MAX_IMAGE_INPUT_PIXELS: 25_000_000,
19+
buildImageResizeSideGrid: () => [1200],
20+
getImageMetadata: getImageMetadataMock,
21+
isImageProcessorUnavailableError: (error: unknown) => error === backendUnavailableError,
22+
readImageMetadataFromHeader: readImageMetadataFromHeaderMock,
23+
resizeToJpeg: async () => {
24+
throw backendUnavailableError;
25+
},
26+
}));
27+
return await import("./tool-images.js");
28+
}
29+
30+
describe("tool image sanitizer without native image backend", () => {
31+
beforeEach(() => {
32+
getImageMetadataMock.mockReset();
33+
readImageMetadataFromHeaderMock.mockReset();
34+
});
35+
36+
it("keeps small header-verified images without probing the backend", async () => {
37+
readImageMetadataFromHeaderMock.mockReturnValueOnce({ width: 32, height: 24 });
38+
getImageMetadataMock.mockRejectedValueOnce(backendUnavailableError);
39+
const { sanitizeContentBlocksImages } = await importSanitizer();
40+
41+
const out = await sanitizeContentBlocksImages(
42+
[{ type: "image" as const, data: PNG_BASE64, mimeType: "image/png" }],
43+
"test",
44+
{ maxDimensionPx: 64, maxBytes: 1024 },
45+
);
46+
47+
expect(out).toStrictEqual([{ type: "image", data: PNG_BASE64, mimeType: "image/png" }]);
48+
expect(getImageMetadataMock).not.toHaveBeenCalled();
49+
});
50+
51+
it("drops images that need resizing when the backend is unavailable", async () => {
52+
readImageMetadataFromHeaderMock.mockReturnValueOnce({ width: 128, height: 24 });
53+
const { sanitizeContentBlocksImages } = await importSanitizer();
54+
55+
const out = await sanitizeContentBlocksImages(
56+
[{ type: "image" as const, data: PNG_BASE64, mimeType: "image/png" }],
57+
"test",
58+
{ maxDimensionPx: 64, maxBytes: 1024 },
59+
);
60+
61+
expect(out).toStrictEqual([
62+
{
63+
type: "text",
64+
text: "[test] omitted image payload: Error: missing image backend",
65+
},
66+
]);
67+
});
68+
69+
it("does not pass through compressed images over the pixel cap", async () => {
70+
readImageMetadataFromHeaderMock.mockReturnValueOnce({ width: 6000, height: 6000 });
71+
const { sanitizeContentBlocksImages } = await importSanitizer();
72+
73+
const out = await sanitizeContentBlocksImages(
74+
[{ type: "image" as const, data: PNG_BASE64, mimeType: "image/png" }],
75+
"test",
76+
{ maxDimensionPx: 6000, maxBytes: 1024 },
77+
);
78+
79+
expect(out).toStrictEqual([
80+
{
81+
type: "text",
82+
text: "[test] omitted image payload: Error: missing image backend",
83+
},
84+
]);
85+
});
86+
});

src/agents/tool-images.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
getImageMetadata,
1313
IMAGE_REDUCE_QUALITY_STEPS,
1414
isImageProcessorUnavailableError,
15+
MAX_IMAGE_INPUT_PIXELS,
16+
readImageMetadataFromHeader,
1517
resizeToJpeg,
18+
type ImageMetadata,
1619
} from "../media/media-services.js";
1720
import {
1821
DEFAULT_IMAGE_MAX_BYTES,
@@ -64,6 +67,26 @@ function inferMimeTypeFromBase64(base64: string): string | undefined {
6467
return undefined;
6568
}
6669

70+
function imageWithinLimits(
71+
buffer: Buffer,
72+
metadata: ImageMetadata | null,
73+
maxDimensionPx: number,
74+
maxBytes: number,
75+
): metadata is ImageMetadata {
76+
const width = metadata?.width;
77+
const height = metadata?.height;
78+
return (
79+
typeof width === "number" &&
80+
typeof height === "number" &&
81+
width > 0 &&
82+
height > 0 &&
83+
buffer.byteLength <= maxBytes &&
84+
width <= maxDimensionPx &&
85+
height <= maxDimensionPx &&
86+
width * height <= MAX_IMAGE_INPUT_PIXELS
87+
);
88+
}
89+
6790
function formatBytesShort(bytes: number): string {
6891
if (!Number.isFinite(bytes) || bytes < 1024) {
6992
return `${Math.max(0, Math.round(bytes))}B`;
@@ -147,19 +170,24 @@ async function resizeImageBase64IfNeeded(params: {
147170
height?: number;
148171
}> {
149172
const buf = Buffer.from(params.base64, "base64");
150-
const meta = await getImageMetadata(buf);
173+
const headerMeta = readImageMetadataFromHeader(buf);
174+
if (imageWithinLimits(buf, headerMeta, params.maxDimensionPx, params.maxBytes)) {
175+
return {
176+
base64: params.base64,
177+
mimeType: params.mimeType,
178+
resized: false,
179+
width: headerMeta.width,
180+
height: headerMeta.height,
181+
};
182+
}
183+
const meta = headerMeta ?? (await getImageMetadata(buf));
151184
const width = meta?.width;
152185
const height = meta?.height;
153186
const overBytes = buf.byteLength > params.maxBytes;
154187
const hasDimensions = typeof width === "number" && typeof height === "number";
155188
const overDimensions =
156189
hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx);
157-
if (
158-
hasDimensions &&
159-
!overBytes &&
160-
width <= params.maxDimensionPx &&
161-
height <= params.maxDimensionPx
162-
) {
190+
if (imageWithinLimits(buf, meta, params.maxDimensionPx, params.maxBytes)) {
163191
return {
164192
base64: params.base64,
165193
mimeType: params.mimeType,

0 commit comments

Comments
 (0)