diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 5edb62a789c..1d80ac9d08b 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -51,6 +51,7 @@ vi.mock('./upload-file-reader', () => ({ })) import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' +import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' import { executeVfsGlob, executeVfsGrep, executeVfsRead } from './vfs' const OVERSIZED_INLINE_CONTENT = 'x'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) @@ -118,10 +119,9 @@ describe('vfs handlers oversize policy', () => { it('fails file-backed oversized read placeholders with original message', async () => { const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue({ - content: '[File too large to display inline: big.txt (6000000 bytes, limit 5242880)]', - totalLines: 1, - }) + vfs.readFileContent.mockResolvedValue( + readPlaceholder.fileTooLarge('big.txt', 6_000_000, 5_242_880) + ) getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( @@ -179,21 +179,85 @@ describe('vfs handlers oversize policy', () => { expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('file') }) - it('fails oversized image placeholder when image exceeds size limit', async () => { + /** + * Every size refusal is a failed read, whichever path produced it. Built from the + * producers so one that stops tagging itself `oversized` fails here rather than + * silently downgrading a refusal to a one-line "successful" read. + */ + it.each([ + ['image', readPlaceholder.imageTooLarge('huge.png', 99, 5)], + ['file', readPlaceholder.fileTooLarge('huge.txt', 99, 5)], + ['document', readPlaceholder.documentTooLarge('huge.pdf', 99, 5)], + ['compiled artifact', readPlaceholder.compiledArtifactTooLarge('app.js', 99, 5)], + ])('fails the read when a %s exceeds its size limit', async (_kind, placeholder) => { const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue({ - content: '[Image too large: huge.png (10.0MB, limit 5MB)]', - totalLines: 1, - }) + vfs.readFileContent.mockResolvedValue(placeholder) getOrMaterializeVFS.mockResolvedValue(vfs) const result = await executeVfsRead( - { path: 'files/huge.png/content' }, + { path: 'files/huge/content' }, { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } ) expect(result.success).toBe(false) - expect(result.error).toContain('too large') + // The placeholder verbatim, not the generic "grep this instead" fallback. + expect(result.error).toBe(placeholder.content) + }) + + it('still fails the read when the stored name contains a newline', async () => { + // Nothing about the message text decides this, so a name that would break a + // text-shape match cannot hide a refusal. + const vfs = makeVfs() + const placeholder = readPlaceholder.fileTooLarge('we\nird.txt', 99, 5) + vfs.readFileContent.mockResolvedValue(placeholder) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/weird/content' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(false) + expect(result.error).toBe(placeholder.content) + }) + + it('returns a real file whose content is exactly a size-refusal message', async () => { + // Untagged, so it is content. Recognising refusals by their text would turn this + // user's file into a tool error instead of returning it. + const vfs = makeVfs() + const { content } = readPlaceholder.documentTooLarge('huge.pdf', 99, 5) + vfs.readFileContent.mockResolvedValue({ content, totalLines: 1 }) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/notes.md/content' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect((result.output as { content?: string })?.content).toBe(content) + }) + + it('returns an undecodable image placeholder as content, not as a size failure', async () => { + const vfs = makeVfs() + // Not a size problem — the bytes were read fine and the reason is already in the + // message, so the model should see it rather than a "too large, use grep" error. + const placeholder = readPlaceholder.imageUnavailable( + 'bomb.png', + 90, + 'It is too large to decode safely.' + ) + const content = placeholder.content + vfs.readFileContent.mockResolvedValue(placeholder) + getOrMaterializeVFS.mockResolvedValue(vfs) + + const result = await executeVfsRead( + { path: 'files/bomb.png/content' }, + { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'ws-1' } + ) + + expect(result.success).toBe(true) + expect((result.output as { content?: string })?.content).toBe(content) }) it('reads canonical file leaf metadata without fetching dynamic content', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index b70cd2d9a23..6fc89b09016 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -8,6 +8,7 @@ import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { isOversizedReadPlaceholder } from '@/lib/copilot/vfs/read-placeholders' import { importWorkspaceFileSecretProvenanceForModelView, type WorkspaceFileSecretProvenanceIdentity, @@ -76,14 +77,6 @@ function serializedResultSize(value: unknown): number { } } -function isOversizedReadPlaceholder(content: string): boolean { - return ( - content.startsWith('[File too large to display inline:') || - content.startsWith('[Image too large:') || - content.startsWith('[Compiled artifact too large:') - ) -} - function hasModelAttachment(result: unknown): boolean { if (!result || typeof result !== 'object') { return false @@ -337,7 +330,7 @@ export async function executeVfsRead( const isAttachment = hasModelAttachment(uploadResult) if ( !isAttachment && - (isOversizedReadPlaceholder(uploadResult.content) || + (isOversizedReadPlaceholder(uploadResult) || serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS) ) { logger.warn('Upload read result too large', { @@ -348,7 +341,7 @@ export async function executeVfsRead( }) return { success: false, - error: isOversizedReadPlaceholder(uploadResult.content) + error: isOversizedReadPlaceholder(uploadResult) ? uploadResult.content : // Same as the workspace-file branch below: this size gate runs on // the whole upload before any window, so "retry with offset/limit" @@ -407,7 +400,7 @@ export async function executeVfsRead( const isAttachment = hasModelAttachment(fileContent) if ( !isAttachment && - (isOversizedReadPlaceholder(fileContent.content) || + (isOversizedReadPlaceholder(fileContent) || serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS) ) { logger.warn('File read result too large', { @@ -418,7 +411,7 @@ export async function executeVfsRead( }) return { success: false, - error: isOversizedReadPlaceholder(fileContent.content) + error: isOversizedReadPlaceholder(fileContent) ? fileContent.content : 'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.', } @@ -466,7 +459,7 @@ export async function executeVfsRead( } if ( !hasModelAttachment(result) && - (isOversizedReadPlaceholder(result.content) || + (isOversizedReadPlaceholder(result) || serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS) ) { return { diff --git a/apps/sim/lib/copilot/vfs/file-reader.test.ts b/apps/sim/lib/copilot/vfs/file-reader.test.ts index 5f070a01c06..531240786b6 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.test.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.test.ts @@ -3,7 +3,8 @@ */ import { randomFillSync } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' +import { crc32 } from 'node:zlib' +import { beforeEach, describe, expect, it, vi } from 'vitest' const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({ fetchWorkspaceFileBuffer: vi.fn(), @@ -13,9 +14,15 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer, })) -import { readFileRecord } from '@/lib/copilot/vfs/file-reader' - -const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 +import { + MAX_IMAGE_READ_BYTES, + MAX_IMAGE_SOURCE_BYTES, + MAX_PARSEABLE_READ_BYTES, + MAX_TEXT_READ_BYTES, + readFileRecord, +} from '@/lib/copilot/vfs/file-reader' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { MAX_TRANSCODE_INPUT_BYTES } from '@/lib/uploads/server/heic' async function makeNoisePng(width: number, height: number): Promise { const sharp = (await import('sharp')).default @@ -26,9 +33,154 @@ async function makeNoisePng(width: number, height: number): Promise { .toBuffer() } +/** + * A decompression bomb: a few hundred bytes on the wire declaring a raster far too + * large to decode. Built by rewriting the IHDR dimensions of a real PNG rather than + * by rendering one, because rendering the raster is the very cost under test. + */ +async function makeBombPng(width: number, height: number): Promise { + const sharp = (await import('sharp')).default + const png = await sharp({ create: { width: 1, height: 1, channels: 3, background: '#fff' } }) + .png() + .toBuffer() + png.writeUInt32BE(width, 16) + png.writeUInt32BE(height, 20) + // IHDR's CRC covers the chunk type and data — bytes 12..29 of a PNG. + png.writeUInt32BE(crc32(png.subarray(12, 29)), 29) + return png +} + +function imageRecord(name: string, size: number, type = 'image/png') { + return { + id: 'wf_img', + workspaceId: 'ws_1', + name, + key: `uploads/${name}`, + path: `/api/files/serve/uploads%2F${name}?context=mothership`, + size, + type, + uploadedBy: 'user_1', + uploadedAt: new Date(), + deletedAt: null, + storageContext: 'mothership' as const, + } +} + const SHARP_TEST_TIMEOUT_MS = 30_000 describe('readFileRecord', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it( + 'rejects a decompression bomb without decoding its raster', + async () => { + // 9e8 pixels — ~3.6GB once decoded as RGBA. + const bomb = await makeBombPng(30_000, 30_000) + expect(bomb.length).toBeLessThan(MAX_IMAGE_READ_BYTES) + + fetchWorkspaceFileBuffer.mockResolvedValue(bomb) + + // Recorded size deliberately disagrees with the real bytes: it is client-declared, + // so the placeholder must report what was actually fetched. + const result = await readFileRecord(imageRecord('bomb.png', 999_999)) + + expect(result?.attachment).toBeUndefined() + expect(result?.content).toContain('It is too large to decode safely.') + // The byte count must survive formatting too — a sub-1KB bomb formatted without + // `includeBytes` collapses to "0 Bytes" next to the real reason. + expect(result?.content).toContain(`(${bomb.length} Bytes)`) + }, + SHARP_TEST_TIMEOUT_MS + ) + + it.each([ + ['48MP iPhone', 8064, 6048], + ['61MP full-frame', 9504, 6336], + ['102MP medium format', 11648, 8736], + ])('does not refuse a %s frame on pixel count', async (_camera, width, height) => { + // Guards the ceiling from being tightened below real hardware. These reach the + // resize ladder and fail there on the stub's truncated pixel data — what matters + // is that they are not turned away by the pixel budget first. + fetchWorkspaceFileBuffer.mockResolvedValue(await makeBombPng(width, height)) + + const result = await readFileRecord(imageRecord('photo.png', 4_000_000)) + + expect(result?.content).not.toContain('It is too large to decode safely.') + }) + + it('reports the too-large placeholder when an understated record.size hides an oversized object', async () => { + fetchWorkspaceFileBuffer.mockRejectedValue( + new PayloadSizeLimitError({ + label: 'workspace file', + maxBytes: MAX_IMAGE_SOURCE_BYTES, + observedBytes: MAX_IMAGE_SOURCE_BYTES + 5_000, + }) + ) + + const result = await readFileRecord(imageRecord('understated.png', 1024)) + + expect(result?.attachment).toBeUndefined() + expect(result?.content).toContain('Image too large to read inline') + // The observed size, not the understated 1024 the cap exists to distrust. + expect(result?.content).toContain(`${MAX_IMAGE_SOURCE_BYTES + 5_000} bytes`) + // And the cap was actually handed to the download — the placeholder alone would + // still appear if the argument were dropped, since the mock rejects regardless. + expect(fetchWorkspaceFileBuffer).toHaveBeenCalledWith(expect.anything(), { + maxBytes: MAX_IMAGE_SOURCE_BYTES, + }) + }) + + it.each([ + ['text', 'notes.txt', 'text/plain', MAX_TEXT_READ_BYTES, 'File too large to display inline'], + [ + 'document', + 'report.pdf', + 'application/pdf', + MAX_PARSEABLE_READ_BYTES, + 'Document too large to parse inline', + ], + ])( + 'caps the %s download and reports the observed size when it breaches', + async (_kind, name, type, cap, expected) => { + fetchWorkspaceFileBuffer.mockRejectedValue( + new PayloadSizeLimitError({ + label: 'workspace file', + maxBytes: cap, + observedBytes: cap + 7_000, + }) + ) + + const result = await readFileRecord(imageRecord(name, 1024, type)) + + expect(result?.content).toContain(expected) + expect(result?.content).toContain(`${cap + 7_000} bytes`) + expect(fetchWorkspaceFileBuffer).toHaveBeenCalledWith(expect.anything(), { maxBytes: cap }) + } + ) + + it('reports an oversized HEIF as a size refusal, not as a corrupt file', async () => { + // `ftyp`+`heic` brand, past the WebAssembly transcoder's own tighter ceiling. + const heif = Buffer.alloc(MAX_TRANSCODE_INPUT_BYTES + 1) + heif.write('ftypheic', 4, 'ascii') + fetchWorkspaceFileBuffer.mockResolvedValue(heif) + + const result = await readFileRecord(imageRecord('photo.heic', heif.length, 'image/heic')) + + expect(result?.attachment).toBeUndefined() + expect(result?.content).toContain('It is too large to decode safely.') + expect(result?.content).not.toContain('It could not be decoded.') + }) + + it('rejects an oversized image on its stored size before fetching it', async () => { + const result = await readFileRecord(imageRecord('huge.png', MAX_IMAGE_SOURCE_BYTES + 1)) + + expect(fetchWorkspaceFileBuffer).not.toHaveBeenCalled() + expect(result?.attachment).toBeUndefined() + expect(result?.content).toContain('Image too large to read inline') + }) + it( 'downscales oversized images into attachments that fit the read limit', async () => { @@ -37,19 +189,7 @@ describe('readFileRecord', () => { fetchWorkspaceFileBuffer.mockResolvedValue(largePng) - const result = await readFileRecord({ - id: 'wf_large', - workspaceId: 'ws_1', - name: 'chesspng.png', - key: 'uploads/chesspng.png', - path: '/api/files/serve/uploads%2Fchesspng.png?context=mothership', - size: largePng.length, - type: 'image/png', - uploadedBy: 'user_1', - uploadedAt: new Date(), - deletedAt: null, - storageContext: 'mothership', - }) + const result = await readFileRecord(imageRecord('chesspng.png', largePng.length)) expect(result?.attachment?.type).toBe('image') expect(result?.content).toContain('resized for vision') diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index e23c647b77c..ac08c7d3e9c 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -12,10 +12,18 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { recordFileRead } from '@/lib/copilot/request/metrics' import { markSpanForError } from '@/lib/copilot/request/otel' +import { type PlaceholderKind, readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { isHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic' import { + isHeifContainer, + MAX_TRANSCODE_INPUT_BYTES, + transcodeHeicToJpeg, +} from '@/lib/uploads/server/heic' +import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' +import { + formatFileSize, isImageFileType, MODEL_SUPPORTED_IMAGE_MIME_TYPES, resolveEffectiveMimeType, @@ -34,11 +42,43 @@ const logger = createLogger('FileReader') /** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */ export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB -const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB +/** Vision-attachment cap: what the prepared image must fit into after resizing. */ +export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB // Parseable-document byte cap. Large office/PDF files can still // produce huge extracted text; reject up front to avoid wasting a // download + parse only to blow past the tool-result budget. -const MAX_PARSEABLE_READ_BYTES = 5 * 1024 * 1024 // 5 MB +export const MAX_PARSEABLE_READ_BYTES = 5 * 1024 * 1024 // 5 MB +/** + * Source-image byte ceiling, checked before the download and enforced by it. This + * route holds the whole file in worker memory, so it reuses the ceiling the FormData + * upload route set for that same failure mode rather than inventing a number. + * + * It does NOT cover everything a user can store: presigned and multipart uploads + * accept up to `MAX_WORKSPACE_FILE_SIZE` (gigabytes), so an image above this ceiling + * is stored fine and simply cannot be read inline. That is a deliberate trade — the + * alternative is buffering a multi-gigabyte file to answer one read — and it is a + * memory budget, not part of the decompression-bomb defence, which is the pixel + * budget below. A bomb is small; no byte cap would catch it. + */ +export const MAX_IMAGE_SOURCE_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE +/** + * Pixel ceiling on the decoded image, and the actual decompression-bomb defence: a + * few hundred KB of PNG can declare an arbitrarily large raster. + * + * This is sharp's own default rather than a number of our own — the vulnerability + * was disabling it with `limitInputPixels: false`, so restoring it is the fix, and + * any tighter value would be us inventing a ceiling the library did not ask for. A + * round 100MP looked tidy but lands mid-market: a Fuji GFX 100 frame is 11648x8736, + * or 101.7MP, and would have been refused. + * + * The cost it bounds is CPU, not memory. libvips decodes this pipeline sequentially, + * so peak RSS stays flat (tens of MB) whatever the header declares — measured here, + * 100MP..1024MP all sat under ~120MB. Time scales sublinearly: ~240ms at 100MP, + * ~400ms at 256MP, ~1.35s at 1024MP, once per resize rung. So the budget caps the + * worst case at roughly 400ms a rung, the `break` below caps a failing image at four + * rungs, and an unbounded declaration — which is what `false` allowed — is gone. + */ +const MAX_IMAGE_INPUT_PIXELS = 268_402_689 const MAX_IMAGE_DIMENSION = 1568 const IMAGE_RESIZE_DIMENSIONS = [1568, 1280, 1024, 768] const IMAGE_QUALITY_STEPS = [85, 70, 55, 40] @@ -69,6 +109,35 @@ function getExtension(filename: string): string { return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '' } +/** + * Download a record under an authoritative byte cap. `record.size` is client-declared, + * so a caller's own size check can pass while the real bytes do not — this is the + * check that actually holds. + * + * On a breach it reports the observed size when the error carries one, because the + * recorded size is exactly the figure this cap exists to distrust: quoting it back + * would print a tiny number beside a much larger limit. + */ +type CappedFetch = { buffer: Buffer } | { tooLarge: true; observedBytes?: number } + +async function fetchWithinLimit( + record: WorkspaceFileRecord, + maxBytes: number +): Promise { + try { + return { buffer: await fetchWorkspaceFileBuffer(record, { maxBytes }) } + } catch (err) { + if (!isPayloadSizeLimitError(err)) throw err + logger.warn('Workspace file exceeded its read cap', { + fileName: record.name, + recordedSize: record.size, + observedBytes: err.observedBytes, + maxBytes, + }) + return { tooLarge: true, observedBytes: err.observedBytes } + } +} + function detectImageMime(buf: Buffer, claimed: string): string { if (buf.length < 12) return claimed if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' @@ -85,6 +154,15 @@ interface PreparedVisionImage { resized: boolean } +/** Shown to the model verbatim, so each value names the one thing that failed. */ +const VisionImageRejection = { + Undecodable: 'It could not be decoded.', + TooLargeToDecode: 'It is too large to decode safely.', + TooLargeAfterResize: `It still exceeded the ${formatFileSize(MAX_IMAGE_READ_BYTES)} vision limit after resizing.`, +} + +type VisionImageResult = { ok: true; image: PreparedVisionImage } | { ok: false; reason: string } + /** * Prepare an image for vision models: detect media type, optionally * resize/compress with sharp, and return the prepared buffer. @@ -98,7 +176,7 @@ interface PreparedVisionImage { async function prepareImageForVision( sourceBuffer: Buffer, claimedType: string -): Promise { +): Promise { return getVfsTracer().startActiveSpan( TraceSpan.CopilotVfsPrepareImage, { @@ -107,7 +185,7 @@ async function prepareImageForVision( [TraceAttr.CopilotVfsInputMediaTypeClaimed]: claimedType, }, }, - async (span) => { + async (span): Promise => { try { const detectedType = detectImageMime(sourceBuffer, claimedType) span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, detectedType) @@ -126,13 +204,21 @@ async function prepareImageForVision( sourceBuffer.length <= MAX_IMAGE_READ_BYTES span.setAttribute( TraceAttr.CopilotVfsOutcome, - fitsWithoutSharp ? 'passthrough_no_sharp' : 'rejected_no_sharp' + fitsWithoutSharp + ? CopilotVfsOutcome.PassthroughNoSharp + : CopilotVfsOutcome.RejectedNoSharp ) - return fitsWithoutSharp - ? { buffer: sourceBuffer, mediaType: detectedType, resized: false } - : null + if (!fitsWithoutSharp) return { ok: false, reason: VisionImageRejection.Undecodable } + return { + ok: true, + image: { buffer: sourceBuffer, mediaType: detectedType, resized: false }, + } } + // Left unguarded deliberately: metadata() only parses the header, so it + // allocates nothing proportional to the declared dimensions, and enabling the + // guard here would route an oversized image into the passthrough branch below + // — which hands the bytes to the model instead of refusing them. const readMetadata = (candidate: Buffer) => sharpModule(candidate, { limitInputPixels: false }) .metadata() @@ -153,6 +239,16 @@ async function prepareImageForVision( let metadata = await readMetadata(sourceBuffer) if (!metadata && isHeifContainer(sourceBuffer)) { + // The WebAssembly fallback is single-threaded and holds its own ceiling, + // well under the source cap above. Say so rather than letting the transcode + // decline and report the file as corrupt. + if (sourceBuffer.length > MAX_TRANSCODE_INPUT_BYTES) { + logger.warn('Rejected HEIF above the transcode ceiling', { + bytes: sourceBuffer.length, + ceiling: MAX_TRANSCODE_INPUT_BYTES, + }) + return { ok: false, reason: VisionImageRejection.TooLargeToDecode } + } const transcoded = await transcodeHeicToJpeg(sourceBuffer) if (transcoded) { buffer = transcoded @@ -169,9 +265,12 @@ async function prepareImageForVision( MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mediaType) && buffer.length <= MAX_IMAGE_READ_BYTES span.setAttribute( TraceAttr.CopilotVfsOutcome, - passthroughViable ? 'passthrough_no_metadata' : 'rejected_no_metadata' + passthroughViable + ? CopilotVfsOutcome.PassthroughNoMetadata + : CopilotVfsOutcome.RejectedNoMetadata ) - return passthroughViable ? { buffer, mediaType, resized: false } : null + if (!passthroughViable) return { ok: false, reason: VisionImageRejection.Undecodable } + return { ok: true, image: { buffer, mediaType, resized: false } } } const width = metadata.width ?? 0 @@ -181,6 +280,23 @@ async function prepareImageForVision( [TraceAttr.CopilotVfsInputHeight]: height, }) + const pixels = width * height + if (pixels > MAX_IMAGE_INPUT_PIXELS) { + logger.warn('Rejected image above the decode pixel budget', { + mediaType, + width, + height, + pixels, + budget: MAX_IMAGE_INPUT_PIXELS, + bytes: buffer.length, + }) + // No `CopilotVfsOutcome` member covers a pre-decode refusal, and that + // vocabulary is generated from a contract this repo does not own — emitting + // an unlisted value would just be dropped downstream. The dimensions are on + // the span above and the reason is in the warning. + return { ok: false, reason: VisionImageRejection.TooLargeToDecode } + } + // A format the model cannot decode has to be re-encoded even when it is // already small enough — the ladder below emits JPEG or WebP, both of // which it accepts. @@ -196,7 +312,7 @@ async function prepareImageForVision( [TraceAttr.CopilotVfsOutputBytes]: buffer.length, [TraceAttr.CopilotVfsOutputMediaType]: mediaType, }) - return { buffer, mediaType, resized: false } + return { ok: true, image: { buffer, mediaType, resized: false } } } const hasAlpha = Boolean( @@ -208,16 +324,20 @@ async function prepareImageForVision( span.setAttribute(TraceAttr.CopilotVfsHasAlpha, hasAlpha) let attempts = 0 + // Separates "cannot be decoded at all" from "decodes fine, never small enough". + let encodedAny = false for (const dimension of IMAGE_RESIZE_DIMENSIONS) { for (const quality of IMAGE_QUALITY_STEPS) { attempts += 1 try { - const pipeline = sharpModule(buffer, { limitInputPixels: false }).rotate().resize({ - width: dimension, - height: dimension, - fit: 'inside', - withoutEnlargement: true, - }) + const pipeline = sharpModule(buffer, { limitInputPixels: MAX_IMAGE_INPUT_PIXELS }) + .rotate() + .resize({ + width: dimension, + height: dimension, + fit: 'inside', + withoutEnlargement: true, + }) const transformed = hasAlpha ? { @@ -233,6 +353,7 @@ async function prepareImageForVision( mediaType: 'image/jpeg', } + encodedAny = true span.addEvent(TraceEvent.CopilotVfsResizeAttempt, { [TraceAttr.CopilotVfsResizeDimension]: dimension, [TraceAttr.CopilotVfsResizeQuality]: quality, @@ -262,12 +383,22 @@ async function prepareImageForVision( [TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.Resized, }) return { - buffer: transformed.buffer, - mediaType: transformed.mediaType, - resized: true, + ok: true, + image: { + buffer: transformed.buffer, + mediaType: transformed.mediaType, + resized: true, + }, } } } catch (err) { + // Next dimension, not next quality: every quality rung re-decodes the + // same source and only varies the encoder, so a failure here almost + // always repeats. Dropping a dimension is the one thing that can change + // the outcome (JPEG shrinks on load), and it bounds a bomb at 4 decodes + // instead of 16. A genuinely encoder-only failure would lose its lower + // quality rungs at that dimension — no such failure mode is known, and + // 4 attempts is the deliberate ceiling. logger.warn('Failed image resize attempt for VFS read', { mediaType, dimension, @@ -279,6 +410,7 @@ async function prepareImageForVision( [TraceAttr.CopilotVfsResizeQuality]: quality, [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), }) + break } } } @@ -288,7 +420,12 @@ async function prepareImageForVision( [TraceAttr.CopilotVfsResizeAttempts]: attempts, [TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.RejectedTooLargeAfterResize, }) - return null + return { + ok: false, + reason: encodedAny + ? VisionImageRejection.TooLargeAfterResize + : VisionImageRejection.Undecodable, + } } catch (err) { recordSpanError(span, err) throw err @@ -302,6 +439,8 @@ async function prepareImageForVision( export interface FileReadResult { content: string totalLines: number + /** Set when `content` stands in for the file rather than being it — see `readPlaceholder`. */ + placeholder?: PlaceholderKind attachment?: { type: string name?: string @@ -342,33 +481,46 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise { span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) - return { - 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.]`, - totalLines: 1, - } + return readPlaceholder.imageTooLarge(record.name, bytes, MAX_IMAGE_SOURCE_BYTES) } - const sizeKb = (prepared.buffer.length / 1024).toFixed(1) - const resizeNote = prepared.resized ? ', resized for vision' : '' + // The recorded size only skips a doomed download; the cap on the download + // itself is what bounds the bytes actually read. + if (record.size > MAX_IMAGE_SOURCE_BYTES) return imageTooLarge(record.size) + const fetched = await fetchWithinLimit(record, MAX_IMAGE_SOURCE_BYTES) + if ('tooLarge' in fetched) return imageTooLarge(fetched.observedBytes ?? record.size) + + const prepared = await prepareImageForVision(fetched.buffer, record.type) + if (!prepared.ok) { + span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) + // The fetched buffer, not `record.size`: the bytes are in hand by now, so + // there is no reason to quote the client-declared figure back. + return readPlaceholder.imageUnavailable( + record.name, + fetched.buffer.length, + prepared.reason + ) + } + const { buffer, mediaType, resized } = prepared.image + const sizeKb = (buffer.length / 1024).toFixed(1) + const resizeNote = resized ? ', resized for vision' : '' span.setAttributes({ [TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.ImagePrepared, - [TraceAttr.CopilotVfsReadOutputBytes]: prepared.buffer.length, - [TraceAttr.CopilotVfsReadOutputMediaType]: prepared.mediaType, - [TraceAttr.CopilotVfsReadImageResized]: prepared.resized, + [TraceAttr.CopilotVfsReadOutputBytes]: buffer.length, + [TraceAttr.CopilotVfsReadOutputMediaType]: mediaType, + [TraceAttr.CopilotVfsReadImageResized]: resized, }) return { - content: `Image: ${record.name} (${sizeKb}KB, ${prepared.mediaType}${resizeNote})`, + content: `Image: ${record.name} (${sizeKb}KB, ${mediaType}${resizeNote})`, totalLines: 1, attachment: { type: 'image', name: record.name, source: { type: 'base64' as const, - media_type: prepared.mediaType, - data: prepared.buffer.toString('base64'), + media_type: mediaType, + data: buffer.toString('base64'), }, }, } @@ -376,15 +528,15 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_TEXT_READ_BYTES) { + const textTooLarge = (bytes: number) => { span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge) - return { - content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`, - totalLines: 1, - } + return readPlaceholder.fileTooLarge(record.name, bytes, MAX_TEXT_READ_BYTES) } + if (record.size > MAX_TEXT_READ_BYTES) return textTooLarge(record.size) - const buffer = await fetchWorkspaceFileBuffer(record) + const fetched = await fetchWithinLimit(record, MAX_TEXT_READ_BYTES) + if ('tooLarge' in fetched) return textTooLarge(fetched.observedBytes ?? record.size) + const buffer = fetched.buffer const content = buffer.toString('utf-8') const lines = content.split('\n').length span.setAttributes({ @@ -398,20 +550,21 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_PARSEABLE_READ_BYTES) { + const documentTooLarge = (bytes: number) => { span.setAttribute( TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.DocumentTooLarge ) - return { - content: `[Document too large to parse inline: ${record.name} (${record.size} bytes, limit ${MAX_PARSEABLE_READ_BYTES})]`, - totalLines: 1, - } + return readPlaceholder.documentTooLarge(record.name, bytes, MAX_PARSEABLE_READ_BYTES) + } + if (record.size > MAX_PARSEABLE_READ_BYTES) return documentTooLarge(record.size) + const fetched = await fetchWithinLimit(record, MAX_PARSEABLE_READ_BYTES) + if ('tooLarge' in fetched) { + return documentTooLarge(fetched.observedBytes ?? record.size) } - const buffer = await fetchWorkspaceFileBuffer(record) try { const { parseBuffer } = await import('@/lib/file-parsers') - const result = await parseBuffer(buffer, ext) + const result = await parseBuffer(fetched.buffer, ext) const content = result.content || '' const lines = content.split('\n').length span.setAttributes({ @@ -430,10 +583,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise { return new Map(entries) @@ -194,3 +195,43 @@ describe('grep regex safety', () => { expect(grep(files, 'alpha')).toHaveLength(1) }) }) + +describe('grepReadResult placeholders', () => { + const grepResult = (result: { + content: string + totalLines: number + placeholder?: 'oversized' | 'unreadable' + }) => grepReadResult('files/x.png/content', result, 'x', 'files/x.png/content') + + /** + * Built from the producers rather than hand-assembled: covers every builder, so + * one that stops tagging itself fails here. + */ + const everyPlaceholder = Object.entries({ + fileTooLarge: readPlaceholder.fileTooLarge('big.txt', 99, 5), + imageTooLarge: readPlaceholder.imageTooLarge('huge.png', 99, 5), + imageUnavailable: readPlaceholder.imageUnavailable('bomb.png', 90, 'It could not be decoded.'), + documentTooLarge: readPlaceholder.documentTooLarge('big.pdf', 99, 5), + compiledArtifactTooLarge: readPlaceholder.compiledArtifactTooLarge('app.js', 99, 5), + couldNotParse: readPlaceholder.couldNotParse('x.pdf', 'application/pdf', 10), + binaryFile: readPlaceholder.binaryFile('app.bin', 'application/octet-stream', 10), + }) + + it.each(everyPlaceholder)( + 'reports the %s placeholder instead of grepping it', + (_name, result) => { + expect(() => grepResult(result)).toThrow(WorkspaceFileGrepError) + expect(() => grepResult(result)).toThrow(result.content) + } + ) + + it('still greps ordinary single-line content', () => { + expect(grepResult({ content: 'x marks the spot', totalLines: 1 })).toHaveLength(1) + }) + + it('greps a real file whose content is exactly a placeholder message', () => { + // Untagged, so it is content — text alone never makes something a placeholder. + const { content } = readPlaceholder.binaryFile('app.bin', 'text/plain', 10) + expect(grepResult({ content, totalLines: 1 })).toHaveLength(1) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index c3ee423cd7d..ac6d87c78a6 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -1,6 +1,10 @@ import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import micromatch from 'micromatch' +import { + isNonGreppablePlaceholder, + type PlaceholderKind, +} from '@/lib/copilot/vfs/read-placeholders' import { compileLinearRegex, isPlainText, @@ -64,18 +68,6 @@ export class WorkspaceFileGrepError extends Error { } } -/** - * True when file content is one of `readFileRecord`'s non-text placeholders - * (binary, unparseable, or over the inline read cap) — these carry no searchable - * content, so grepping them should report the placeholder instead. - */ -function isNonGreppablePlaceholder(content: string, totalLines: number): boolean { - if (totalLines !== 1) return false - return /^\[(File too large|Image too large|Document too large|Could not parse|Binary file|Compiled artifact too large)/.test( - content.trim() - ) -} - /** * Run a single-file content grep over an already-resolved file read result, * shared by workspace-file grep (`WorkspaceVFS.grepFile`) and chat-upload grep. @@ -86,7 +78,12 @@ function isNonGreppablePlaceholder(content: string, totalLines: number): boolean */ export function grepReadResult( path: string, - result: { content: string; totalLines: number; attachment?: unknown }, + result: { + content: string + totalLines: number + placeholder?: PlaceholderKind + attachment?: unknown + }, pattern: string, readHint: string, options?: GrepOptions @@ -96,7 +93,7 @@ export function grepReadResult( `Cannot grep "${path}" — it has no searchable text (image/binary). Use read("${readHint}") to view it.` ) } - if (isNonGreppablePlaceholder(result.content, result.totalLines)) { + if (isNonGreppablePlaceholder(result)) { throw new WorkspaceFileGrepError(result.content) } return grep(new Map([[path, result.content]]), pattern, undefined, options) @@ -105,6 +102,7 @@ export function grepReadResult( export interface ReadResult { content: string totalLines: number + placeholder?: PlaceholderKind } /** diff --git a/apps/sim/lib/copilot/vfs/read-placeholders.ts b/apps/sim/lib/copilot/vfs/read-placeholders.ts new file mode 100644 index 00000000000..634b5f3fe0a --- /dev/null +++ b/apps/sim/lib/copilot/vfs/read-placeholders.ts @@ -0,0 +1,87 @@ +/** + * The stand-ins a VFS read returns in place of file content. + * + * A placeholder carries its own classification rather than being recognised by its + * text later. Sniffing the string was wrong twice over: gates drifted from the + * wording they were matching (one tested a prefix no producer emitted, so it never + * fired), and a real file whose content merely opened like a placeholder was + * misclassified as one. Neither is possible when the producer states what it built. + */ + +import { formatFileSize } from '@/lib/uploads/utils/file-utils' + +/** + * `oversized` — the file is intact but reading it was refused on size, so the read + * handler reports a tool error rather than a one-line "success". + * + * `unreadable` — the read produced an answer, just not text: undecodable, binary, or + * unparseable. Callers get it as content. Both are equally unsearchable. + */ +export type PlaceholderKind = 'oversized' | 'unreadable' + +export interface ReadPlaceholder { + content: string + totalLines: 1 + placeholder: PlaceholderKind +} + +function placeholder(kind: PlaceholderKind, content: string): ReadPlaceholder { + return { content, totalLines: 1, placeholder: kind } +} + +export const readPlaceholder = { + fileTooLarge: (name: string, bytes: number, limit: number) => + placeholder( + 'oversized', + `[File too large to display inline: ${name} (${bytes} bytes, limit ${limit})]` + ), + imageTooLarge: (name: string, bytes: number, limit: number) => + placeholder( + 'oversized', + `[Image too large to read inline: ${name} (${bytes} bytes, limit ${limit})]` + ), + documentTooLarge: (name: string, bytes: number, limit: number) => + placeholder( + 'oversized', + `[Document too large to parse inline: ${name} (${bytes} bytes, limit ${limit})]` + ), + compiledArtifactTooLarge: (name: string, bytes: number, limit: number) => + placeholder( + 'oversized', + `[Compiled artifact too large: ${name} (${bytes} bytes, limit ${limit})]` + ), + /** + * Not `oversized` even when the reason is a size: it also covers an undecodable or + * unsupported image, which is an answer rather than a refusal. + * + * Formats here rather than at the call site — without `includeBytes` every sub-1KB + * file, which is every decompression bomb, prints as "0 Bytes". + */ + imageUnavailable: (name: string, bytes: number, reason: string) => + placeholder( + 'unreadable', + `[Image unavailable: ${name} (${formatFileSize(bytes, { includeBytes: true })}). ${reason}]` + ), + couldNotParse: (name: string, type: string, bytes: number) => + placeholder('unreadable', `[Could not parse ${name} (${type}, ${bytes} bytes)]`), + binaryFile: (name: string, type: string, bytes: number) => + placeholder( + 'unreadable', + `[Binary file: ${name} (${type}, ${bytes} bytes). Cannot display as text.]` + ), +} as const + +/** A read result, which may or may not have been produced by this module. */ +interface MaybePlaceholder { + placeholder?: PlaceholderKind +} + +/** A size refusal, which the read handler surfaces as a tool error. */ +export function isOversizedReadPlaceholder(result: MaybePlaceholder): boolean { + return result.placeholder === 'oversized' +} + +/** Any placeholder — none of them carry text worth searching. */ +export function isNonGreppablePlaceholder(result: MaybePlaceholder): boolean { + return result.placeholder !== undefined +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 251e4cf0a4a..d11769bc963 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -62,6 +62,7 @@ import { canonicalWorkspaceFilePath, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' +import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' import type { DeploymentData, KbTagDefinitionSummary, @@ -1236,10 +1237,14 @@ export class WorkspaceVFS { ) } if (compiled.length > MAX_COMPILED_ATTACHMENT_BYTES) { - return bindWorkspaceFileResult(record, { - content: `[Compiled artifact too large: ${record.name} (${compiled.length} bytes, limit ${MAX_COMPILED_ATTACHMENT_BYTES})]`, - totalLines: 1, - }) + return bindWorkspaceFileResult( + record, + readPlaceholder.compiledArtifactTooLarge( + record.name, + compiled.length, + MAX_COMPILED_ATTACHMENT_BYTES + ) + ) } return bindWorkspaceFileResult( record, diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 4bae36791dc..dbe1a921bbc 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -26,6 +26,7 @@ import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { DbOrTx } from '@/lib/db/types' import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' @@ -1251,6 +1252,10 @@ export async function fetchWorkspaceFileBuffer( return buffer } catch (error) { logger.error(`Failed to download workspace file ${fileRecord.name}:`, error) + // Rethrow a `maxBytes` breach unwrapped: callers distinguish "too large" from a + // transport failure to answer with their own placeholder, and re-wrapping it in a + // plain Error would erase the only thing that tells the two apart. + if (isPayloadSizeLimitError(error)) throw error throw new Error(`Failed to download file: ${getErrorMessage(error, 'Unknown error')}`) } } diff --git a/apps/sim/lib/uploads/server/heic.ts b/apps/sim/lib/uploads/server/heic.ts index 5ebc570e24a..b0fbf8c1c68 100644 --- a/apps/sim/lib/uploads/server/heic.ts +++ b/apps/sim/lib/uploads/server/heic.ts @@ -19,16 +19,15 @@ const HEVC_HEIF_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx' const HEIF_BRANDS = new Set([...HEVC_HEIF_BRANDS, 'mif1', 'msf1', 'avif', 'avis']) /** - * Byte ceiling for a fallback decode. Uploads allow 100MB and the vision path runs - * sharp with `limitInputPixels: false`, so without this a tenant could push an - * arbitrarily large HEIF through a single-threaded WebAssembly decode. 20MB leaves - * generous headroom over any phone photo — a 12MP iPhone HEIC is 1-4MB — while - * bounding what one read can cost. + * Byte ceiling for a fallback decode. Uploads allow 100MB, so without this a tenant + * could push an arbitrarily large HEIF through a single-threaded WebAssembly decode. + * 20MB leaves generous headroom over any phone photo — a 12MP iPhone HEIC is 1-4MB — + * while bounding what one read can cost. * * This bounds file size, not pixel count. A small file declaring enormous * dimensions is rejected during parse by libheif's own security limits. */ -const MAX_TRANSCODE_INPUT_BYTES = 20 * 1024 * 1024 +export const MAX_TRANSCODE_INPUT_BYTES = 20 * 1024 * 1024 /** A real `ftyp` box holds a handful of brands; anything larger is malformed or hostile. */ const MAX_FTYP_BOX_BYTES = 512