Skip to content

Commit eafbb35

Browse files
committed
improvement(copilot): bound image decode work on the VFS file read path
Apply explicit size and pixel limits when preparing workspace images for vision, and carry the specific rejection reason through to the read placeholder instead of a generic message. - cap the source image by byte size, enforced by the download itself - give sharp an explicit pixel budget instead of disabling its own - refuse oversized dimensions before the resize ladder runs - retry a failed resize at a smaller dimension rather than re-running the identical decode at each quality step - pass maxBytes on the text and document read paths too
1 parent 6c6d8a5 commit eafbb35

3 files changed

Lines changed: 176 additions & 49 deletions

File tree

apps/sim/lib/copilot/vfs/file-reader.test.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
*/
44

55
import { randomFillSync } from 'node:crypto'
6-
import { describe, expect, it, vi } from 'vitest'
6+
import { crc32 } from 'node:zlib'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
78

89
const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({
910
fetchWorkspaceFileBuffer: vi.fn(),
@@ -16,6 +17,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
1617
import { readFileRecord } from '@/lib/copilot/vfs/file-reader'
1718

1819
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024
20+
const MAX_IMAGE_SOURCE_BYTES = 25 * 1024 * 1024
1921

2022
async function makeNoisePng(width: number, height: number): Promise<Buffer> {
2123
const sharp = (await import('sharp')).default
@@ -26,9 +28,71 @@ async function makeNoisePng(width: number, height: number): Promise<Buffer> {
2628
.toBuffer()
2729
}
2830

31+
/**
32+
* A decompression bomb: a few hundred bytes on the wire declaring a raster far too
33+
* large to decode. Built by rewriting the IHDR dimensions of a real PNG rather than
34+
* by rendering one, because rendering the raster is the very cost under test.
35+
*/
36+
async function makeBombPng(width: number, height: number): Promise<Buffer> {
37+
const sharp = (await import('sharp')).default
38+
const png = await sharp({ create: { width: 1, height: 1, channels: 3, background: '#fff' } })
39+
.png()
40+
.toBuffer()
41+
png.writeUInt32BE(width, 16)
42+
png.writeUInt32BE(height, 20)
43+
// IHDR's CRC covers the chunk type and data — bytes 12..29 of a PNG.
44+
png.writeUInt32BE(crc32(png.subarray(12, 29)), 29)
45+
return png
46+
}
47+
48+
function imageRecord(name: string, size: number, type = 'image/png') {
49+
return {
50+
id: 'wf_img',
51+
workspaceId: 'ws_1',
52+
name,
53+
key: `uploads/${name}`,
54+
path: `/api/files/serve/uploads%2F${name}?context=mothership`,
55+
size,
56+
type,
57+
uploadedBy: 'user_1',
58+
uploadedAt: new Date(),
59+
deletedAt: null,
60+
storageContext: 'mothership' as const,
61+
}
62+
}
63+
2964
const SHARP_TEST_TIMEOUT_MS = 30_000
3065

3166
describe('readFileRecord', () => {
67+
beforeEach(() => {
68+
vi.clearAllMocks()
69+
})
70+
71+
it(
72+
'rejects a decompression bomb without decoding its raster',
73+
async () => {
74+
// 9e8 pixels — ~3.6GB once decoded as RGBA.
75+
const bomb = await makeBombPng(30_000, 30_000)
76+
expect(bomb.length).toBeLessThan(MAX_IMAGE_READ_BYTES)
77+
78+
fetchWorkspaceFileBuffer.mockResolvedValue(bomb)
79+
80+
const result = await readFileRecord(imageRecord('bomb.png', bomb.length))
81+
82+
expect(result?.attachment).toBeUndefined()
83+
expect(result?.content).toContain('It is too large to decode safely.')
84+
},
85+
SHARP_TEST_TIMEOUT_MS
86+
)
87+
88+
it('rejects an oversized image on its stored size before fetching it', async () => {
89+
const result = await readFileRecord(imageRecord('huge.png', MAX_IMAGE_SOURCE_BYTES + 1))
90+
91+
expect(fetchWorkspaceFileBuffer).not.toHaveBeenCalled()
92+
expect(result?.attachment).toBeUndefined()
93+
expect(result?.content).toContain('Image too large to read inline')
94+
})
95+
3296
it(
3397
'downscales oversized images into attachments that fit the read limit',
3498
async () => {
@@ -37,19 +101,7 @@ describe('readFileRecord', () => {
37101

38102
fetchWorkspaceFileBuffer.mockResolvedValue(largePng)
39103

40-
const result = await readFileRecord({
41-
id: 'wf_large',
42-
workspaceId: 'ws_1',
43-
name: 'chesspng.png',
44-
key: 'uploads/chesspng.png',
45-
path: '/api/files/serve/uploads%2Fchesspng.png?context=mothership',
46-
size: largePng.length,
47-
type: 'image/png',
48-
uploadedBy: 'user_1',
49-
uploadedAt: new Date(),
50-
deletedAt: null,
51-
storageContext: 'mothership',
52-
})
104+
const result = await readFileRecord(imageRecord('chesspng.png', largePng.length))
53105

54106
expect(result?.attachment?.type).toBe('image')
55107
expect(result?.content).toContain('resized for vision')

apps/sim/lib/copilot/vfs/file-reader.ts

Lines changed: 106 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/works
1616
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1717
import { isHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic'
1818
import {
19+
formatFileSize,
1920
isImageFileType,
2021
MODEL_SUPPORTED_IMAGE_MIME_TYPES,
2122
resolveEffectiveMimeType,
@@ -39,6 +40,15 @@ const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
3940
// produce huge extracted text; reject up front to avoid wasting a
4041
// download + parse only to blow past the tool-result budget.
4142
const MAX_PARSEABLE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
43+
/** Source-image byte ceiling. Sits above the 20MB HEIC transcode ceiling, so a HEIF is bounded by the tighter of the two. */
44+
const MAX_IMAGE_SOURCE_BYTES = 25 * 1024 * 1024
45+
/**
46+
* Pixel ceiling on the decoded raster. libvips materialises the whole raster, and
47+
* an allocation this large OOM-kills the process rather than throwing, so it has to
48+
* be refused up front. 100MP caps the decode near 400MB while clearing every real
49+
* camera — a 48MP iPhone still is 8064x6048.
50+
*/
51+
const MAX_IMAGE_INPUT_PIXELS = 100_000_000
4252
const MAX_IMAGE_DIMENSION = 1568
4353
const IMAGE_RESIZE_DIMENSIONS = [1568, 1280, 1024, 768]
4454
const IMAGE_QUALITY_STEPS = [85, 70, 55, 40]
@@ -85,6 +95,20 @@ interface PreparedVisionImage {
8595
resized: boolean
8696
}
8797

98+
/**
99+
* Shown to the model verbatim in the read placeholder, so each value names the one
100+
* thing that actually failed rather than a disjunction of everything that might have.
101+
*/
102+
const VisionImageRejection = {
103+
Undecodable: 'It could not be decoded.',
104+
TooManyPixels: 'It is too large to decode safely.',
105+
TooLargeAfterResize: 'It still exceeded the 5MB vision limit after resizing.',
106+
} as const
107+
108+
type VisionImageResult =
109+
| { ok: true; image: PreparedVisionImage }
110+
| { ok: false; reason: (typeof VisionImageRejection)[keyof typeof VisionImageRejection] }
111+
88112
/**
89113
* Prepare an image for vision models: detect media type, optionally
90114
* resize/compress with sharp, and return the prepared buffer.
@@ -98,7 +122,7 @@ interface PreparedVisionImage {
98122
async function prepareImageForVision(
99123
sourceBuffer: Buffer,
100124
claimedType: string
101-
): Promise<PreparedVisionImage | null> {
125+
): Promise<VisionImageResult> {
102126
return getVfsTracer().startActiveSpan(
103127
TraceSpan.CopilotVfsPrepareImage,
104128
{
@@ -107,7 +131,7 @@ async function prepareImageForVision(
107131
[TraceAttr.CopilotVfsInputMediaTypeClaimed]: claimedType,
108132
},
109133
},
110-
async (span) => {
134+
async (span): Promise<VisionImageResult> => {
111135
try {
112136
const detectedType = detectImageMime(sourceBuffer, claimedType)
113137
span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, detectedType)
@@ -128,11 +152,17 @@ async function prepareImageForVision(
128152
TraceAttr.CopilotVfsOutcome,
129153
fitsWithoutSharp ? 'passthrough_no_sharp' : 'rejected_no_sharp'
130154
)
131-
return fitsWithoutSharp
132-
? { buffer: sourceBuffer, mediaType: detectedType, resized: false }
133-
: null
155+
if (!fitsWithoutSharp) return { ok: false, reason: VisionImageRejection.Undecodable }
156+
return {
157+
ok: true,
158+
image: { buffer: sourceBuffer, mediaType: detectedType, resized: false },
159+
}
134160
}
135161

162+
// Left unguarded deliberately: metadata() only parses the header, so it
163+
// allocates nothing proportional to the declared dimensions, and enabling the
164+
// guard here would route an oversized image into the passthrough branch below
165+
// — which hands the bytes to the model instead of refusing them.
136166
const readMetadata = (candidate: Buffer) =>
137167
sharpModule(candidate, { limitInputPixels: false })
138168
.metadata()
@@ -171,7 +201,8 @@ async function prepareImageForVision(
171201
TraceAttr.CopilotVfsOutcome,
172202
passthroughViable ? 'passthrough_no_metadata' : 'rejected_no_metadata'
173203
)
174-
return passthroughViable ? { buffer, mediaType, resized: false } : null
204+
if (!passthroughViable) return { ok: false, reason: VisionImageRejection.Undecodable }
205+
return { ok: true, image: { buffer, mediaType, resized: false } }
175206
}
176207

177208
const width = metadata.width ?? 0
@@ -181,6 +212,20 @@ async function prepareImageForVision(
181212
[TraceAttr.CopilotVfsInputHeight]: height,
182213
})
183214

215+
const pixels = width * height
216+
if (pixels > MAX_IMAGE_INPUT_PIXELS) {
217+
logger.warn('Rejected image above the decode pixel budget', {
218+
mediaType,
219+
width,
220+
height,
221+
pixels,
222+
budget: MAX_IMAGE_INPUT_PIXELS,
223+
bytes: buffer.length,
224+
})
225+
span.setAttribute(TraceAttr.CopilotVfsOutcome, 'rejected_pixel_budget')
226+
return { ok: false, reason: VisionImageRejection.TooManyPixels }
227+
}
228+
184229
// A format the model cannot decode has to be re-encoded even when it is
185230
// already small enough — the ladder below emits JPEG or WebP, both of
186231
// which it accepts.
@@ -196,7 +241,7 @@ async function prepareImageForVision(
196241
[TraceAttr.CopilotVfsOutputBytes]: buffer.length,
197242
[TraceAttr.CopilotVfsOutputMediaType]: mediaType,
198243
})
199-
return { buffer, mediaType, resized: false }
244+
return { ok: true, image: { buffer, mediaType, resized: false } }
200245
}
201246

202247
const hasAlpha = Boolean(
@@ -208,16 +253,19 @@ async function prepareImageForVision(
208253
span.setAttribute(TraceAttr.CopilotVfsHasAlpha, hasAlpha)
209254

210255
let attempts = 0
256+
let decodeFailed = false
211257
for (const dimension of IMAGE_RESIZE_DIMENSIONS) {
212258
for (const quality of IMAGE_QUALITY_STEPS) {
213259
attempts += 1
214260
try {
215-
const pipeline = sharpModule(buffer, { limitInputPixels: false }).rotate().resize({
216-
width: dimension,
217-
height: dimension,
218-
fit: 'inside',
219-
withoutEnlargement: true,
220-
})
261+
const pipeline = sharpModule(buffer, { limitInputPixels: MAX_IMAGE_INPUT_PIXELS })
262+
.rotate()
263+
.resize({
264+
width: dimension,
265+
height: dimension,
266+
fit: 'inside',
267+
withoutEnlargement: true,
268+
})
221269

222270
const transformed = hasAlpha
223271
? {
@@ -262,12 +310,20 @@ async function prepareImageForVision(
262310
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.Resized,
263311
})
264312
return {
265-
buffer: transformed.buffer,
266-
mediaType: transformed.mediaType,
267-
resized: true,
313+
ok: true,
314+
image: {
315+
buffer: transformed.buffer,
316+
mediaType: transformed.mediaType,
317+
resized: true,
318+
},
268319
}
269320
}
270321
} catch (err) {
322+
// Move to the next dimension rather than the next quality: the quality
323+
// rungs re-decode the identical source and only change the encoder, so
324+
// repeating a failed decode there is pure waste. A smaller dimension is
325+
// worth trying — libvips shrinks JPEG on load, so it decodes less.
326+
decodeFailed = true
271327
logger.warn('Failed image resize attempt for VFS read', {
272328
mediaType,
273329
dimension,
@@ -279,6 +335,7 @@ async function prepareImageForVision(
279335
[TraceAttr.CopilotVfsResizeQuality]: quality,
280336
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
281337
})
338+
break
282339
}
283340
}
284341
}
@@ -288,7 +345,12 @@ async function prepareImageForVision(
288345
[TraceAttr.CopilotVfsResizeAttempts]: attempts,
289346
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.RejectedTooLargeAfterResize,
290347
})
291-
return null
348+
return {
349+
ok: false,
350+
reason: decodeFailed
351+
? VisionImageRejection.Undecodable
352+
: VisionImageRejection.TooLargeAfterResize,
353+
}
292354
} catch (err) {
293355
recordSpanError(span, err)
294356
throw err
@@ -342,33 +404,45 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
342404
// image down the binary path where the model never sees it.
343405
if (isImageFileType(resolveEffectiveMimeType(record.type, record.name))) {
344406
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image)
345-
const originalBuffer = await fetchWorkspaceFileBuffer(record)
407+
// `record.size` is client-declared, so it only buys the friendly placeholder;
408+
// the download's own `maxBytes` is what actually bounds the bytes read.
409+
if (record.size > MAX_IMAGE_SOURCE_BYTES) {
410+
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
411+
return {
412+
content: `[Image too large to read inline: ${record.name} (${record.size} bytes, limit ${MAX_IMAGE_SOURCE_BYTES})]`,
413+
totalLines: 1,
414+
}
415+
}
416+
const originalBuffer = await fetchWorkspaceFileBuffer(record, {
417+
maxBytes: MAX_IMAGE_SOURCE_BYTES,
418+
})
346419
const prepared = await prepareImageForVision(originalBuffer, record.type)
347-
if (!prepared) {
420+
if (!prepared.ok) {
348421
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
349422
return {
350-
content: `[Image unavailable: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB). It could not be decoded, or still exceeded the 5MB vision limit after resizing.]`,
423+
content: `[Image unavailable: ${record.name} (${formatFileSize(record.size)}). ${prepared.reason}]`,
351424
totalLines: 1,
352425
}
353426
}
354-
const sizeKb = (prepared.buffer.length / 1024).toFixed(1)
355-
const resizeNote = prepared.resized ? ', resized for vision' : ''
427+
const { buffer, mediaType, resized } = prepared.image
428+
const sizeKb = (buffer.length / 1024).toFixed(1)
429+
const resizeNote = resized ? ', resized for vision' : ''
356430
span.setAttributes({
357431
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.ImagePrepared,
358-
[TraceAttr.CopilotVfsReadOutputBytes]: prepared.buffer.length,
359-
[TraceAttr.CopilotVfsReadOutputMediaType]: prepared.mediaType,
360-
[TraceAttr.CopilotVfsReadImageResized]: prepared.resized,
432+
[TraceAttr.CopilotVfsReadOutputBytes]: buffer.length,
433+
[TraceAttr.CopilotVfsReadOutputMediaType]: mediaType,
434+
[TraceAttr.CopilotVfsReadImageResized]: resized,
361435
})
362436
return {
363-
content: `Image: ${record.name} (${sizeKb}KB, ${prepared.mediaType}${resizeNote})`,
437+
content: `Image: ${record.name} (${sizeKb}KB, ${mediaType}${resizeNote})`,
364438
totalLines: 1,
365439
attachment: {
366440
type: 'image',
367441
name: record.name,
368442
source: {
369443
type: 'base64' as const,
370-
media_type: prepared.mediaType,
371-
data: prepared.buffer.toString('base64'),
444+
media_type: mediaType,
445+
data: buffer.toString('base64'),
372446
},
373447
},
374448
}
@@ -384,7 +458,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
384458
}
385459
}
386460

387-
const buffer = await fetchWorkspaceFileBuffer(record)
461+
const buffer = await fetchWorkspaceFileBuffer(record, { maxBytes: MAX_TEXT_READ_BYTES })
388462
const content = buffer.toString('utf-8')
389463
const lines = content.split('\n').length
390464
span.setAttributes({
@@ -408,7 +482,9 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
408482
totalLines: 1,
409483
}
410484
}
411-
const buffer = await fetchWorkspaceFileBuffer(record)
485+
const buffer = await fetchWorkspaceFileBuffer(record, {
486+
maxBytes: MAX_PARSEABLE_READ_BYTES,
487+
})
412488
try {
413489
const { parseBuffer } = await import('@/lib/file-parsers')
414490
const result = await parseBuffer(buffer, ext)

apps/sim/lib/uploads/server/heic.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,10 @@ const HEVC_HEIF_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx'
1919
const HEIF_BRANDS = new Set([...HEVC_HEIF_BRANDS, 'mif1', 'msf1', 'avif', 'avis'])
2020

2121
/**
22-
* Byte ceiling for a fallback decode. Uploads allow 100MB and the vision path runs
23-
* sharp with `limitInputPixels: false`, so without this a tenant could push an
24-
* arbitrarily large HEIF through a single-threaded WebAssembly decode. 20MB leaves
25-
* generous headroom over any phone photo — a 12MP iPhone HEIC is 1-4MB — while
26-
* bounding what one read can cost.
22+
* Byte ceiling for a fallback decode. Uploads allow 100MB, so without this a tenant
23+
* could push an arbitrarily large HEIF through a single-threaded WebAssembly decode.
24+
* 20MB leaves generous headroom over any phone photo — a 12MP iPhone HEIC is 1-4MB —
25+
* while bounding what one read can cost.
2726
*
2827
* This bounds file size, not pixel count. A small file declaring enormous
2928
* dimensions is rejected during parse by libheif's own security limits.

0 commit comments

Comments
 (0)