Skip to content

Commit 04a225f

Browse files
committed
fix(copilot): keep the too-large placeholder when the download cap trips
The recorded size is client-declared, so the download's maxBytes is the check that actually holds. Breaching it threw past the placeholder and surfaced as a failed read on all three capped paths. Rethrow PayloadSizeLimitError unwrapped from fetchWorkspaceFileBuffer so callers can tell a size breach from a transport error, and answer with the same too-large placeholder the recorded-size check already returns.
1 parent 8444fc3 commit 04a225f

3 files changed

Lines changed: 79 additions & 21 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
1515
}))
1616

1717
import { readFileRecord } from '@/lib/copilot/vfs/file-reader'
18+
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1819

1920
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024
2021
const MAX_IMAGE_SOURCE_BYTES = 25 * 1024 * 1024
@@ -85,6 +86,19 @@ describe('readFileRecord', () => {
8586
SHARP_TEST_TIMEOUT_MS
8687
)
8788

89+
it('reports the too-large placeholder when a understated record.size hides an oversized object', async () => {
90+
// `record.size` is client-declared, so the download cap is the check that holds —
91+
// and breaching it must still read as "too large", not as a failed read.
92+
fetchWorkspaceFileBuffer.mockRejectedValue(
93+
new PayloadSizeLimitError({ label: 'workspace file', maxBytes: MAX_IMAGE_SOURCE_BYTES })
94+
)
95+
96+
const result = await readFileRecord(imageRecord('understated.png', 1024))
97+
98+
expect(result?.attachment).toBeUndefined()
99+
expect(result?.content).toContain('Image too large to read inline')
100+
})
101+
88102
it('rejects an oversized image on its stored size before fetching it', async () => {
89103
const result = await readFileRecord(imageRecord('huge.png', MAX_IMAGE_SOURCE_BYTES + 1))
90104

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

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
1212
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
1313
import { recordFileRead } from '@/lib/copilot/request/metrics'
1414
import { markSpanForError } from '@/lib/copilot/request/otel'
15+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
1516
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1617
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1718
import { isHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic'
@@ -79,6 +80,29 @@ function getExtension(filename: string): string {
7980
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : ''
8081
}
8182

83+
/**
84+
* Download a record under an authoritative byte cap, returning null when the stored
85+
* object breaches it. `record.size` is client-declared, so a caller's own size check
86+
* can pass while the real bytes do not — this is the check that actually holds, and
87+
* null lets the caller answer with its too-large placeholder rather than a read failure.
88+
*/
89+
async function fetchWithinLimit(
90+
record: WorkspaceFileRecord,
91+
maxBytes: number
92+
): Promise<Buffer | null> {
93+
try {
94+
return await fetchWorkspaceFileBuffer(record, { maxBytes })
95+
} catch (err) {
96+
if (!isPayloadSizeLimitError(err)) throw err
97+
logger.warn('Workspace file exceeded its read cap', {
98+
fileName: record.name,
99+
recordedSize: record.size,
100+
maxBytes,
101+
})
102+
return null
103+
}
104+
}
105+
82106
function detectImageMime(buf: Buffer, claimed: string): string {
83107
if (buf.length < 12) return claimed
84108
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
@@ -404,18 +428,22 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
404428
// image down the binary path where the model never sees it.
405429
if (isImageFileType(resolveEffectiveMimeType(record.type, record.name))) {
406430
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image)
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.
431+
// `record.size` is client-declared, so it only skips a doomed download; the
432+
// download's own `maxBytes` is what actually bounds the bytes read. Both
433+
// answer with the same placeholder.
434+
const imageTooLarge = {
435+
content: `[Image too large to read inline: ${record.name} (${record.size} bytes, limit ${MAX_IMAGE_SOURCE_BYTES})]`,
436+
totalLines: 1,
437+
}
409438
if (record.size > MAX_IMAGE_SOURCE_BYTES) {
410439
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-
}
440+
return imageTooLarge
441+
}
442+
const originalBuffer = await fetchWithinLimit(record, MAX_IMAGE_SOURCE_BYTES)
443+
if (!originalBuffer) {
444+
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
445+
return imageTooLarge
415446
}
416-
const originalBuffer = await fetchWorkspaceFileBuffer(record, {
417-
maxBytes: MAX_IMAGE_SOURCE_BYTES,
418-
})
419447
const prepared = await prepareImageForVision(originalBuffer, record.type)
420448
if (!prepared.ok) {
421449
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
@@ -450,15 +478,20 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
450478

451479
if (isReadableFileType(record.type)) {
452480
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Text)
481+
const textTooLarge = {
482+
content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`,
483+
totalLines: 1,
484+
}
453485
if (record.size > MAX_TEXT_READ_BYTES) {
454486
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge)
455-
return {
456-
content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`,
457-
totalLines: 1,
458-
}
487+
return textTooLarge
459488
}
460489

461-
const buffer = await fetchWorkspaceFileBuffer(record, { maxBytes: MAX_TEXT_READ_BYTES })
490+
const buffer = await fetchWithinLimit(record, MAX_TEXT_READ_BYTES)
491+
if (!buffer) {
492+
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge)
493+
return textTooLarge
494+
}
462495
const content = buffer.toString('utf-8')
463496
const lines = content.split('\n').length
464497
span.setAttributes({
@@ -472,19 +505,25 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
472505
const ext = getExtension(record.name)
473506
if (PARSEABLE_EXTENSIONS.has(ext)) {
474507
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.ParseableDocument)
508+
const documentTooLarge = {
509+
content: `[Document too large to parse inline: ${record.name} (${record.size} bytes, limit ${MAX_PARSEABLE_READ_BYTES})]`,
510+
totalLines: 1,
511+
}
475512
if (record.size > MAX_PARSEABLE_READ_BYTES) {
476513
span.setAttribute(
477514
TraceAttr.CopilotVfsReadOutcome,
478515
CopilotVfsReadOutcome.DocumentTooLarge
479516
)
480-
return {
481-
content: `[Document too large to parse inline: ${record.name} (${record.size} bytes, limit ${MAX_PARSEABLE_READ_BYTES})]`,
482-
totalLines: 1,
483-
}
517+
return documentTooLarge
518+
}
519+
const buffer = await fetchWithinLimit(record, MAX_PARSEABLE_READ_BYTES)
520+
if (!buffer) {
521+
span.setAttribute(
522+
TraceAttr.CopilotVfsReadOutcome,
523+
CopilotVfsReadOutcome.DocumentTooLarge
524+
)
525+
return documentTooLarge
484526
}
485-
const buffer = await fetchWorkspaceFileBuffer(record, {
486-
maxBytes: MAX_PARSEABLE_READ_BYTES,
487-
})
488527
try {
489528
const { parseBuffer } = await import('@/lib/file-parsers')
490529
const result = await parseBuffer(buffer, ext)

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
2626
import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
2727
import { generateRequestId } from '@/lib/core/utils/request'
2828
import { generateRestoreName } from '@/lib/core/utils/restore-name'
29+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
2930
import type { DbOrTx } from '@/lib/db/types'
3031
import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify'
3132
import { getServePathPrefix } from '@/lib/uploads'
@@ -1251,6 +1252,10 @@ export async function fetchWorkspaceFileBuffer(
12511252
return buffer
12521253
} catch (error) {
12531254
logger.error(`Failed to download workspace file ${fileRecord.name}:`, error)
1255+
// Rethrow a `maxBytes` breach unwrapped: callers distinguish "too large" from a
1256+
// transport failure to answer with their own placeholder, and re-wrapping it in a
1257+
// plain Error would erase the only thing that tells the two apart.
1258+
if (isPayloadSizeLimitError(error)) throw error
12541259
throw new Error(`Failed to download file: ${getErrorMessage(error, 'Unknown error')}`)
12551260
}
12561261
}

0 commit comments

Comments
 (0)