Skip to content

Commit 6954859

Browse files
BillLeoutsakosvl346Bill Leoutsakoswaleedlatif1claude
authored
fix(files): preserve valid zero-byte tool outputs (#7642)
* fix(files): preserve valid zero-byte tool outputs * 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 * fix(files): reject whitespace-only base64 payloads Normalization strips whitespace, so a nonempty payload of only spaces or newlines collapsed to the empty encoding and was stored as a zero-byte file. Only a payload that is empty before normalization is a legitimate zero-byte file, so compare against the value after the data: URI prefix is removed — which keeps an empty `data:text/plain;base64,` accepted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VxZCGfFjZnokR8uFeWrfLp --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Waleed Latif <walif6@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 305b5ef commit 6954859

2 files changed

Lines changed: 152 additions & 10 deletions

File tree

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

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,128 @@ describe('FileToolProcessor', () => {
109109

110110
expect(mockUploadExecutionFile).not.toHaveBeenCalled()
111111
})
112+
113+
it.each([Buffer.alloc(0), '', { type: 'Buffer', data: [] }])(
114+
'stores valid zero-byte inline files as UserFile outputs: %j',
115+
async (data) => {
116+
const storedFile = {
117+
id: 'empty-file',
118+
key: 'workspace/workspace-1/empty-file',
119+
name: 'empty.txt',
120+
size: 0,
121+
type: 'text/plain',
122+
url: '/api/files/serve?key=workspace%2Fworkspace-1%2Fempty-file',
123+
} satisfies UserFile
124+
mockUploadExecutionFile.mockResolvedValue(storedFile)
125+
126+
const result = await FileToolProcessor.processToolOutputs(
127+
{ file: { name: 'empty.txt', mimeType: 'text/plain', data } },
128+
toolConfig,
129+
executionContext
130+
)
131+
132+
expect(result.file).toEqual(storedFile)
133+
expect(mockUploadExecutionFile).toHaveBeenCalledWith(
134+
expect.objectContaining({ workspaceId: 'workspace-1', executionId: 'execution-1' }),
135+
Buffer.alloc(0),
136+
'empty.txt',
137+
'text/plain',
138+
'user-1'
139+
)
140+
expect(mockDownloadFileFromUrl).not.toHaveBeenCalled()
141+
}
142+
)
143+
144+
it('preserves empty file entries in file-array outputs', async () => {
145+
const result = await FileToolProcessor.processToolOutputs(
146+
{ file: [{ name: 'empty.txt', mimeType: 'text/plain', data: '' }] },
147+
{ ...toolConfig, outputs: { file: { type: 'file[]' } } },
148+
executionContext
149+
)
150+
151+
expect(result.file).toHaveLength(1)
152+
expect(mockUploadExecutionFile.mock.calls[0]?.[1]).toEqual(Buffer.alloc(0))
153+
})
154+
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+
195+
it('stores an empty base64 data URI as a zero-byte file', async () => {
196+
await FileToolProcessor.processToolOutputs(
197+
{ file: { name: 'empty.txt', mimeType: 'text/plain', data: 'data:text/plain;base64,' } },
198+
toolConfig,
199+
executionContext
200+
)
201+
202+
expect(mockUploadExecutionFile.mock.calls[0]?.[1]).toEqual(Buffer.alloc(0))
203+
})
204+
205+
it('stores a successful zero-byte URL download', async () => {
206+
mockDownloadFileFromUrl.mockResolvedValue(Buffer.alloc(0))
207+
208+
await FileToolProcessor.processToolOutputs(
209+
{ file: { name: 'empty.txt', mimeType: 'text/plain', url: 'https://example.com/empty' } },
210+
toolConfig,
211+
executionContext
212+
)
213+
214+
expect(mockUploadExecutionFile.mock.calls[0]?.[1]).toEqual(Buffer.alloc(0))
215+
})
216+
217+
it.each([
218+
undefined,
219+
null,
220+
'!!!',
221+
'a!b!c!AAAA',
222+
'AAAAA',
223+
' \n\t ',
224+
{ type: 'Buffer', data: 'invalid' },
225+
])('does not turn missing or malformed data into an empty file: %j', async (data) => {
226+
await expect(
227+
FileToolProcessor.processToolOutputs(
228+
{ file: { name: 'invalid.txt', mimeType: 'text/plain', data } },
229+
toolConfig,
230+
executionContext
231+
)
232+
).rejects.toThrow("Failed to process file output 'file'")
233+
234+
expect(mockUploadExecutionFile).not.toHaveBeenCalled()
235+
})
112236
})

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

Lines changed: 28 additions & 10 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,26 @@ const IMAGE_FILE_EXTENSIONS: Record<string, string> = {
1617
'image/webp': 'webp',
1718
}
1819

20+
/**
21+
* Strip a base64 `data:` URI prefix, leaving the encoded payload. An empty payload is
22+
* a legitimate zero-byte file; a payload that only looks empty after normalization is
23+
* not, so callers compare against what this returns rather than the raw value.
24+
*/
25+
function stripBase64DataUri(value: string): string {
26+
return /^data:[^,]*;base64,/i.test(value) ? value.slice(value.indexOf(',') + 1) : value
27+
}
28+
29+
/**
30+
* Normalize a base64 payload to canonical RFC 4648 form so it can be validated: drop
31+
* the line wrapping MIME encoders emit, translate the base64url alphabet, and restore
32+
* the padding unpadded encoders omit.
33+
*/
34+
function normalizeBase64(payload: string): string {
35+
const compact = payload.replace(/\s/g, '').replace(/-/g, '+').replace(/_/g, '/')
36+
const remainder = compact.length % 4
37+
return remainder === 0 ? compact : compact + '='.repeat(4 - remainder)
38+
}
39+
1940
function assertFileSize(size: number, fileName: string): void {
2041
if (size > MAX_FILE_SIZE) {
2142
throw new Error(`File '${fileName}' exceeds the maximum allowed size of ${MAX_FILE_SIZE} bytes`)
@@ -169,29 +190,26 @@ export class FileToolProcessor {
169190
} else {
170191
throw new Error(`Invalid serialized buffer format for ${data.name}`)
171192
}
172-
} else if (typeof data.data === 'string' && data.data) {
173-
let base64Data = data.data
174-
175-
if (base64Data.includes('-') || base64Data.includes('_')) {
176-
base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/')
177-
}
193+
} else if (typeof data.data === 'string') {
194+
const payload = stripBase64DataUri(data.data)
195+
const base64Data = normalizeBase64(payload)
178196

179197
const paddingBytes = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0
180198
assertFileSize(Math.floor((base64Data.length * 3) / 4) - paddingBytes, data.name)
199+
if (!isCanonicalBase64(base64Data) || (payload.length > 0 && base64Data.length === 0)) {
200+
throw new Error(`File '${data.name}' has invalid base64 data`)
201+
}
181202
buffer = Buffer.from(base64Data, 'base64')
182203
}
183204

184-
if (!buffer && data.url) {
205+
if ((!buffer || buffer.length === 0) && data.url) {
185206
buffer = await downloadFileFromUrl(data.url, {
186207
maxBytes: MAX_FILE_SIZE,
187208
userId: context.userId,
188209
})
189210
}
190211

191212
if (buffer) {
192-
if (buffer.length === 0) {
193-
throw new Error(`File '${data.name}' has zero bytes`)
194-
}
195213
assertFileSize(buffer.length, data.name)
196214
const storedMetadata = resolveStoredFileMetadata(data.name, data.mimeType, buffer)
197215

0 commit comments

Comments
 (0)