Skip to content

Commit a095720

Browse files
waleedlatif1claude
andcommitted
fix(files): keep url fallback and validate base64 canonically
Empty inline data no longer shadows a url. `''` and an empty Buffer now produce a zero-byte buffer, which made `if (!buffer && data.url)` skip a download that previously ran, so a tool emitting a placeholder alongside a real url would have stored an empty file. Replace the hand-rolled zero-length base64 check with the shared `isCanonicalBase64`. `Buffer.from(x, 'base64')` silently drops characters outside the alphabet, so `a!b!c!AAAA` and the non-canonical `AAAAA` decoded to plausible-looking bytes instead of failing. Normalize first so the payloads that already worked keep working: line-wrapped MIME base64, unpadded base64url, and a base64 `data:` URI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VxZCGfFjZnokR8uFeWrfLp
1 parent 8a2844c commit a095720

2 files changed

Lines changed: 59 additions & 17 deletions

File tree

apps/sim/executor/utils/file-tool-processor.test.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,7 @@ describe('FileToolProcessor', () => {
124124
mockUploadExecutionFile.mockResolvedValue(storedFile)
125125

126126
const result = await FileToolProcessor.processToolOutputs(
127-
{
128-
file: {
129-
name: 'empty.txt',
130-
mimeType: 'text/plain',
131-
data,
132-
url: 'https://example.com/file',
133-
},
134-
},
127+
{ file: { name: 'empty.txt', mimeType: 'text/plain', data } },
135128
toolConfig,
136129
executionContext
137130
)
@@ -159,6 +152,46 @@ describe('FileToolProcessor', () => {
159152
expect(mockUploadExecutionFile.mock.calls[0]?.[1]).toEqual(Buffer.alloc(0))
160153
})
161154

155+
it.each([Buffer.alloc(0), '', { type: 'Buffer', data: [] }])(
156+
'prefers the url over empty inline data: %j',
157+
async (data) => {
158+
mockDownloadFileFromUrl.mockResolvedValue(Buffer.from('downloaded'))
159+
160+
await FileToolProcessor.processToolOutputs(
161+
{
162+
file: {
163+
name: 'file.txt',
164+
mimeType: 'text/plain',
165+
data,
166+
url: 'https://example.com/file',
167+
},
168+
},
169+
toolConfig,
170+
executionContext
171+
)
172+
173+
expect(mockDownloadFileFromUrl).toHaveBeenCalledWith(
174+
'https://example.com/file',
175+
expect.objectContaining({ userId: 'user-1' })
176+
)
177+
expect(mockUploadExecutionFile.mock.calls[0]?.[1]).toEqual(Buffer.from('downloaded'))
178+
}
179+
)
180+
181+
it.each([
182+
['line-wrapped base64', 'SGVsbG8s\nIHdvcmxkIQ=='],
183+
['unpadded base64url', 'SGVsbG8sIHdvcmxkIQ'],
184+
['a base64 data URI', 'data:text/plain;base64,SGVsbG8sIHdvcmxkIQ=='],
185+
])('decodes %s', async (_label, data) => {
186+
await FileToolProcessor.processToolOutputs(
187+
{ file: { name: 'hello.txt', mimeType: 'text/plain', data } },
188+
toolConfig,
189+
executionContext
190+
)
191+
192+
expect(mockUploadExecutionFile.mock.calls[0]?.[1]).toEqual(Buffer.from('Hello, world!'))
193+
})
194+
162195
it('stores a successful zero-byte URL download', async () => {
163196
mockDownloadFileFromUrl.mockResolvedValue(Buffer.alloc(0))
164197

@@ -171,7 +204,7 @@ describe('FileToolProcessor', () => {
171204
expect(mockUploadExecutionFile.mock.calls[0]?.[1]).toEqual(Buffer.alloc(0))
172205
})
173206

174-
it.each([undefined, null, '!!!', { type: 'Buffer', data: 'invalid' }])(
207+
it.each([undefined, null, '!!!', 'a!b!c!AAAA', 'AAAAA', { type: 'Buffer', data: 'invalid' }])(
175208
'does not turn missing or malformed data into an empty file: %j',
176209
async (data) => {
177210
await expect(

apps/sim/executor/utils/file-tool-processor.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
3+
import { isCanonicalBase64 } from '@/lib/api/contracts/primitives'
34
import { isUserFile } from '@/lib/core/utils/user-file'
45
import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution'
56
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
@@ -16,6 +17,18 @@ const IMAGE_FILE_EXTENSIONS: Record<string, string> = {
1617
'image/webp': 'webp',
1718
}
1819

20+
/**
21+
* Normalize a tool-supplied base64 payload to canonical RFC 4648 form so it can be
22+
* validated: strip a base64 `data:` URI prefix, drop the line wrapping MIME encoders
23+
* emit, translate the base64url alphabet, and restore padding unpadded encoders omit.
24+
*/
25+
function normalizeBase64(value: string): string {
26+
const payload = /^data:[^,]*;base64,/i.test(value) ? value.slice(value.indexOf(',') + 1) : value
27+
const compact = payload.replace(/\s/g, '').replace(/-/g, '+').replace(/_/g, '/')
28+
const remainder = compact.length % 4
29+
return remainder === 0 ? compact : compact + '='.repeat(4 - remainder)
30+
}
31+
1932
function assertFileSize(size: number, fileName: string): void {
2033
if (size > MAX_FILE_SIZE) {
2134
throw new Error(`File '${fileName}' exceeds the maximum allowed size of ${MAX_FILE_SIZE} bytes`)
@@ -170,21 +183,17 @@ export class FileToolProcessor {
170183
throw new Error(`Invalid serialized buffer format for ${data.name}`)
171184
}
172185
} else if (typeof data.data === 'string') {
173-
let base64Data = data.data
174-
175-
if (base64Data.includes('-') || base64Data.includes('_')) {
176-
base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/')
177-
}
186+
const base64Data = normalizeBase64(data.data)
178187

179188
const paddingBytes = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0
180189
assertFileSize(Math.floor((base64Data.length * 3) / 4) - paddingBytes, data.name)
181-
buffer = Buffer.from(base64Data, 'base64')
182-
if (base64Data.length > 0 && buffer.length === 0) {
190+
if (!isCanonicalBase64(base64Data)) {
183191
throw new Error(`File '${data.name}' has invalid base64 data`)
184192
}
193+
buffer = Buffer.from(base64Data, 'base64')
185194
}
186195

187-
if (!buffer && data.url) {
196+
if ((!buffer || buffer.length === 0) && data.url) {
188197
buffer = await downloadFileFromUrl(data.url, {
189198
maxBytes: MAX_FILE_SIZE,
190199
userId: context.userId,

0 commit comments

Comments
 (0)