Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 75 additions & 11 deletions apps/sim/lib/copilot/tools/handlers/vfs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand Down
19 changes: 6 additions & 13 deletions apps/sim/lib/copilot/tools/handlers/vfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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', {
Expand All @@ -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"
Expand Down Expand Up @@ -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', {
Expand All @@ -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.',
}
Expand Down Expand Up @@ -466,7 +459,7 @@ export async function executeVfsRead(
}
if (
!hasModelAttachment(result) &&
(isOversizedReadPlaceholder(result.content) ||
(isOversizedReadPlaceholder(result) ||
serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS)
) {
return {
Expand Down
174 changes: 157 additions & 17 deletions apps/sim/lib/copilot/vfs/file-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<Buffer> {
const sharp = (await import('sharp')).default
Expand All @@ -26,9 +33,154 @@ async function makeNoisePng(width: number, height: number): Promise<Buffer> {
.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<Buffer> {
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 () => {
Expand All @@ -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')
Expand Down
Loading
Loading