Skip to content

Commit e3d3ba5

Browse files
committed
fix(files): preserve rendered attachment semantics
1 parent b9060e1 commit e3d3ba5

8 files changed

Lines changed: 126 additions & 8 deletions

File tree

apps/sim/lib/copilot/tools/server/files/doc-compile.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,8 +541,12 @@ export async function resolveServableDocBytes(args: {
541541
}
542542
}
543543

544-
// Reaches here only for xlsx, which has no isolated-vm fallback.
545-
if (!format) return { buffer: rawBuffer, contentType: getContentType(fileName) }
544+
// Reaches here only for xlsx, which has no isolated-vm fallback. With workspace
545+
// context, returning these bytes would expose generation source as a spreadsheet.
546+
if (!format) {
547+
if (workspaceId) throw new DocCompileUserError('Document is still being generated')
548+
return { buffer: rawBuffer, contentType: getContentType(fileName) }
549+
}
546550

547551
const cacheKey = sha256Hex(`${ext}${source}${workspaceId ?? ''}`)
548552
const cached = compiledDocCache.get(cacheKey)

apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,21 @@ describe('resolveServableDocBytes', () => {
158158
expect(mockRunSandboxTask).not.toHaveBeenCalled()
159159
})
160160

161+
it('throws instead of returning XLSX source when E2B is disabled', async () => {
162+
mockLoadCompiledDoc.mockResolvedValue(null)
163+
setEnvFlags({ isDocSandboxEnabled: false })
164+
165+
await expect(
166+
resolveServableDocBytes({
167+
rawBuffer: XLSX_SOURCE,
168+
fileName: 'sheet.xlsx',
169+
workspaceId: WORKSPACE_ID,
170+
})
171+
).rejects.toBeInstanceOf(DocCompileUserError)
172+
173+
expect(mockRunSandboxTask).not.toHaveBeenCalled()
174+
})
175+
161176
it('returns raw XLSX source when there is no workspaceId (xlsx has no isolated-vm path)', async () => {
162177
const result = await resolveServableDocBytes({
163178
rawBuffer: XLSX_SOURCE,

apps/sim/lib/execution/payloads/materialization.server.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const generatedPdf: UserFile = {
3434
describe('readUserFileContent', () => {
3535
beforeEach(() => {
3636
vi.clearAllMocks()
37+
generatedPdf.size = PDF_SOURCE.length
3738
mockVerifyFileAccess.mockResolvedValue(true)
3839
mockDownloadServableFileFromStorage.mockResolvedValue({
3940
buffer: PDF_BYTES,
@@ -50,5 +51,6 @@ describe('readUserFileContent', () => {
5051
expect(mockDownloadServableFileFromStorage).toHaveBeenCalledOnce()
5152
expect(content).toBe(PDF_BYTES.toString('base64'))
5253
expect(content).not.toBe(PDF_SOURCE.toString('base64'))
54+
expect(generatedPdf.size).toBe(PDF_BYTES.length)
5355
})
5456
})

apps/sim/lib/execution/payloads/materialization.server.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
} from '@/lib/execution/payloads/large-value-ref'
1111
import { ExecutionResourceLimitError } from '@/lib/execution/resource-errors'
1212
import type { StorageContext } from '@/lib/uploads'
13-
import { bufferToBase64, inferContextFromKey } from '@/lib/uploads/utils/file-utils'
13+
import {
14+
bufferToBase64,
15+
inferContextFromKey,
16+
isGeneratedDocumentSourceType,
17+
} from '@/lib/uploads/utils/file-utils'
1418
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
1519
import type { UserFile } from '@/executor/types'
1620

@@ -267,6 +271,11 @@ export async function assertUserFileContentAccess(
267271
}
268272
}
269273

274+
/**
275+
* Reads the bytes a consumer should receive. For generated documents, updates the
276+
* file's size to the rendered artifact size so downstream attachment routing does
277+
* not make decisions from the smaller generation-source size.
278+
*/
270279
export async function readUserFileContent(
271280
file: unknown,
272281
options: ReadUserFileContentOptions
@@ -296,6 +305,9 @@ export async function readUserFileContent(
296305
).buffer
297306
} catch (error) {
298307
if (isPayloadSizeLimitError(error)) {
308+
if (isGeneratedDocumentSourceType(file.type) && error.observedBytes !== undefined) {
309+
file.size = error.observedBytes
310+
}
299311
throw new ExecutionResourceLimitError({
300312
resource: 'execution_payload_bytes',
301313
attemptedBytes: error.observedBytes ?? maxSourceBytes + 1,
@@ -308,6 +320,9 @@ export async function readUserFileContent(
308320
if (!buffer) {
309321
throw new Error(`File content for ${file.name} is unavailable.`)
310322
}
323+
if (isGeneratedDocumentSourceType(file.type)) {
324+
file.size = buffer.length
325+
}
311326
if (buffer.length > maxSourceBytes) {
312327
throw new ExecutionResourceLimitError({
313328
resource: 'execution_payload_bytes',

apps/sim/lib/uploads/utils/user-file-base64.server.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,65 @@ describe('hydrateUserFilesWithBase64', () => {
108108
expect(hydrated.file.base64).toBe(base64)
109109
})
110110

111+
it('uses rendered size when generated source metadata exceeds the inline limit', async () => {
112+
const rendered = Buffer.from('%PDF')
113+
mockDownloadServableFileFromStorage.mockResolvedValueOnce({
114+
buffer: rendered,
115+
contentType: 'application/pdf',
116+
})
117+
const file: UserFile = {
118+
id: 'file-1',
119+
name: 'report.pdf',
120+
key: 'workspace/2f1d8c3e-5b6a-4c7d-8e9f-0a1b2c3d4e5f/report.pdf',
121+
url: '',
122+
size: 11,
123+
type: 'text/x-python-pdf',
124+
}
125+
126+
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10, userId: 'user-1' })
127+
128+
expect(hydrated.file.base64).toBe(rendered.toString('base64'))
129+
expect(hydrated.file.size).toBe(rendered.length)
130+
})
131+
132+
it('records rendered size when a generated document must use a provider upload path', async () => {
133+
mockDownloadServableFileFromStorage.mockResolvedValueOnce({
134+
buffer: Buffer.alloc(11),
135+
contentType: 'application/pdf',
136+
})
137+
const file: UserFile = {
138+
id: 'file-1',
139+
name: 'report.pdf',
140+
key: 'workspace/2f1d8c3e-5b6a-4c7d-8e9f-0a1b2c3d4e5f/report.pdf',
141+
url: '',
142+
size: 1,
143+
type: 'text/x-python-pdf',
144+
}
145+
146+
const hydrated = await hydrateUserFilesWithBase64({ file }, { maxBytes: 10, userId: 'user-1' })
147+
148+
expect(hydrated.file).not.toHaveProperty('base64')
149+
expect(hydrated.file.size).toBe(11)
150+
})
151+
152+
it('propagates generated documents that are still compiling', async () => {
153+
const notReady = new Error('Document is still being generated')
154+
notReady.name = 'DocCompileUserError'
155+
mockDownloadServableFileFromStorage.mockRejectedValueOnce(notReady)
156+
const file: UserFile = {
157+
id: 'file-1',
158+
name: 'report.pdf',
159+
key: 'workspace/2f1d8c3e-5b6a-4c7d-8e9f-0a1b2c3d4e5f/report.pdf',
160+
url: '',
161+
size: 1,
162+
type: 'text/x-python-pdf',
163+
}
164+
165+
await expect(
166+
hydrateUserFilesWithBase64({ file }, { maxBytes: 10, userId: 'user-1' })
167+
).rejects.toBe(notReady)
168+
})
169+
111170
it('does not hydrate URL-only internal file objects', async () => {
112171
const file: UserFile = {
113172
id: 'file-1',

apps/sim/lib/uploads/utils/user-file-base64.server.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ExecutionResourceLimitError,
2828
isExecutionResourceLimitError,
2929
} from '@/lib/execution/resource-errors'
30+
import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils'
3031
import type { UserFile } from '@/executor/types'
3132

3233
const INLINE_BASE64_JSON_OVERHEAD_BYTES = 512 * 1024
@@ -391,7 +392,11 @@ async function resolveBase64(
391392
const allowUnknownSize = options.allowUnknownSize ?? false
392393
const hasStableStorageKey = Boolean(file.key)
393394

394-
if (Number.isFinite(file.size) && file.size > maxBytes) {
395+
if (
396+
!isGeneratedDocumentSourceType(file.type) &&
397+
Number.isFinite(file.size) &&
398+
file.size > maxBytes
399+
) {
395400
logger.warn(
396401
`[${options.requestId}] Skipping base64 for ${file.name} (size ${file.size} exceeds ${maxBytes})`
397402
)
@@ -420,9 +425,11 @@ async function resolveBase64(
420425
userId: options.userId,
421426
encoding: 'base64',
422427
maxBytes,
423-
maxSourceBytes: maxBytes,
424428
})
425429
} catch (error) {
430+
if (error instanceof Error && error.name === 'DocCompileUserError') {
431+
throw error
432+
}
426433
logger.warn(`[${requestId}] Failed to hydrate base64 for ${file.name}`, error)
427434
return null
428435
}

apps/sim/providers/attachments.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,16 @@ describe('provider large-file capability', () => {
310310
expect(shouldUseLargeFilePath(large, 'bedrock')).toBe(false)
311311
})
312312

313+
it('does not expose generated source through a remote-url large-file path', () => {
314+
const generated = {
315+
...pdfFile,
316+
size: INLINE_ATTACHMENT_THRESHOLD_BYTES + 1,
317+
type: 'text/x-python-pdf',
318+
}
319+
expect(shouldUseLargeFilePath(generated, 'openai')).toBe(true)
320+
expect(shouldUseLargeFilePath(generated, 'anthropic')).toBe(false)
321+
})
322+
313323
it('references uploaded OpenAI files by file_id instead of inlining base64', () => {
314324
const content = buildOpenAIMessageContent(
315325
'Analyze',

apps/sim/providers/attachments.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,18 @@ export function getProviderFileStrategy(providerId: ProviderId | string): Provid
8989
return getProviderFileAttachment(providerId).strategy
9090
}
9191

92-
/** True when a file exceeds the inline threshold and the provider has a large-file path. */
92+
/**
93+
* True when an oversized file has a safe provider path. Remote URLs point at the
94+
* primary storage object, so source-backed documents can only use artifact-aware
95+
* Files API uploads.
96+
*/
9397
export function shouldUseLargeFilePath(
94-
file: Pick<UserFile, 'size'>,
98+
file: Pick<UserFile, 'size' | 'type'>,
9599
providerId: ProviderId | string
96100
): boolean {
97-
if (getProviderFileAttachment(providerId).strategy === 'inline') return false
101+
const strategy = getProviderFileAttachment(providerId).strategy
102+
if (strategy === 'inline') return false
103+
if (strategy === 'remote-url' && isGeneratedDocumentSourceType(file.type)) return false
98104
return Number.isFinite(file.size) && file.size > INLINE_ATTACHMENT_THRESHOLD_BYTES
99105
}
100106

0 commit comments

Comments
 (0)