Skip to content

Commit 60744d4

Browse files
committed
Sandbox exports decode by the path rule the reader used: a .jpg declared without a format is written as image/jpeg bytes, not as its base64 text under the json format (thumbnails opened as raw text)
1 parent 3866aef commit 60744d4

5 files changed

Lines changed: 160 additions & 25 deletions

File tree

apps/sim/lib/execution/remote-sandbox/index.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
repairMissingSandboxImage,
3939
resolveWorkspaceSandbox,
4040
} from '@/lib/execution/remote-sandbox/resolve'
41+
import { isBinarySandboxPath } from '@/lib/execution/remote-sandbox/sandbox-encoding'
4142
import {
4243
SANDBOX_OUTPUT_DIR_MAX_DEPTH,
4344
SANDBOX_OUTPUT_DIR_SENTINEL,
@@ -593,25 +594,6 @@ const SIM_RESULT_CORRUPTED_ERROR =
593594
"Do not trust or persist this call's output. For large results, write the content to a " +
594595
'file inside the sandbox and export it via outputs.files[].sandboxPath instead of returning it.'
595596

596-
function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean {
597-
const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase()
598-
const binaryExts = new Set([
599-
'.png',
600-
'.jpg',
601-
'.jpeg',
602-
'.gif',
603-
'.webp',
604-
'.pdf',
605-
'.zip',
606-
'.mp3',
607-
'.mp4',
608-
'.docx',
609-
'.pptx',
610-
'.xlsx',
611-
])
612-
return binaryExts.has(ext)
613-
}
614-
615597
async function readSandboxOutputFile(
616598
sandbox: SandboxHandle,
617599
outputSandboxPath: string,
@@ -621,7 +603,7 @@ async function readSandboxOutputFile(
621603
try {
622604
return await sandbox.readFileWithLimit(outputSandboxPath, {
623605
maxBytes,
624-
encoding: shouldReadSandboxPathAsBase64(outputSandboxPath) ? 'base64' : 'utf8',
606+
encoding: isBinarySandboxPath(outputSandboxPath) ? 'base64' : 'utf8',
625607
signal: options?.signal,
626608
})
627609
} catch (error) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isBinarySandboxPath } from '@/lib/execution/remote-sandbox/sandbox-encoding'
6+
7+
describe('isBinarySandboxPath', () => {
8+
it('treats image, document, and archive extensions as binary regardless of case', () => {
9+
for (const path of [
10+
'/home/user/a.jpg',
11+
'/tmp/B.PNG',
12+
'/x/report.pdf',
13+
'/x/deck.pptx',
14+
'/x/data.xlsx',
15+
'/x/a.zip',
16+
]) {
17+
expect(isBinarySandboxPath(path)).toBe(true)
18+
}
19+
})
20+
21+
it('treats text formats and unknown extensions as text', () => {
22+
for (const path of [
23+
'/home/user/a.json',
24+
'/tmp/rows.csv',
25+
'/x/notes.md',
26+
'/x/script.py',
27+
'/x/noext',
28+
'/x/data.parquet',
29+
]) {
30+
expect(isBinarySandboxPath(path)).toBe(false)
31+
}
32+
})
33+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* How a declared sandbox output path is read back and, therefore, how its content must be
3+
* decoded. The reader (remote-sandbox) and the exporter (function-execution) must agree
4+
* on this per path: a `.jpg` read as base64 and then classified as text by its (unknown)
5+
* output format was stored verbatim as base64 text under an `image/jpeg`-less name.
6+
*/
7+
const BINARY_EXTENSIONS: ReadonlySet<string> = new Set([
8+
'.png',
9+
'.jpg',
10+
'.jpeg',
11+
'.gif',
12+
'.webp',
13+
'.pdf',
14+
'.zip',
15+
'.mp3',
16+
'.mp4',
17+
'.docx',
18+
'.pptx',
19+
'.xlsx',
20+
])
21+
22+
/**
23+
* True when a declared output path is read from the sandbox as base64 bytes rather than
24+
* UTF-8 text.
25+
*/
26+
export function isBinarySandboxPath(sandboxPath: string): boolean {
27+
const ext = sandboxPath.slice(sandboxPath.lastIndexOf('.')).toLowerCase()
28+
return BINARY_EXTENSIONS.has(ext)
29+
}

apps/sim/lib/function-execution/execute-request.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,89 @@ describe('Function execution request', () => {
932932
])
933933
})
934934

935+
it('exports a .jpg declared without a format as image/jpeg bytes, never as base64 text', async () => {
936+
// The sandbox reads a .jpg back as base64; the exporter used to classify it by its
937+
// (unknown) output format, default to json, and store the base64 string verbatim.
938+
envFlagsMock.isRemoteSandboxEnabled = true
939+
const jpegBase64 = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).toString('base64')
940+
mockExecuteInSandbox.mockResolvedValueOnce({
941+
result: 'done',
942+
stdout: 'ok',
943+
sandboxId: 'sandbox-123',
944+
cost: { input: 0, output: 0, total: 0.0001 },
945+
exportedFiles: {
946+
'/home/user/thumbs/01.jpg': jpegBase64,
947+
'/home/user/summary.json': '{"ok":true}',
948+
},
949+
})
950+
951+
const req = createMockRequest('POST', {
952+
code: 'print("done")',
953+
language: 'python',
954+
workspaceId: 'workspace-1',
955+
workflowId: 'workflow-1',
956+
executionId: 'execution-1',
957+
outputs: {
958+
files: [
959+
{
960+
path: 'files/thumbs/01.jpg',
961+
mode: 'create',
962+
sandboxPath: '/home/user/thumbs/01.jpg',
963+
},
964+
{ path: 'files/summary.json', mode: 'create', sandboxPath: '/home/user/summary.json' },
965+
],
966+
},
967+
})
968+
969+
const response = await POST(req)
970+
expect(response.status).toBe(200)
971+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2)
972+
const [jpgCall, jsonCall] = mockWriteWorkspaceFileByPath.mock.calls.map((call) => call[0])
973+
expect(jpgCall.target).toEqual(expect.objectContaining({ path: 'files/thumbs/01.jpg' }))
974+
expect(jpgCall.inferredMimeType).toBe('image/jpeg')
975+
expect(Buffer.from(jpgCall.buffer).equals(Buffer.from(jpegBase64, 'base64'))).toBe(true)
976+
expect(jsonCall.target).toEqual(expect.objectContaining({ path: 'files/summary.json' }))
977+
expect(jsonCall.inferredMimeType).toBe('application/json')
978+
expect(Buffer.from(jsonCall.buffer).toString('utf-8')).toBe('{"ok":true}')
979+
})
980+
981+
it('exports a single .jpg declared without a format as image/jpeg bytes (single-file path)', async () => {
982+
envFlagsMock.isRemoteSandboxEnabled = true
983+
const jpegBase64 = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).toString('base64')
984+
mockExecuteInSandbox.mockResolvedValueOnce({
985+
result: 'done',
986+
stdout: 'ok',
987+
sandboxId: 'sandbox-123',
988+
cost: { input: 0, output: 0, total: 0.0001 },
989+
exportedFiles: { '/home/user/thumbs/02.jpg': jpegBase64 },
990+
})
991+
992+
const req = createMockRequest('POST', {
993+
code: 'print("done")',
994+
language: 'python',
995+
workspaceId: 'workspace-1',
996+
workflowId: 'workflow-1',
997+
executionId: 'execution-1',
998+
outputs: {
999+
files: [
1000+
{
1001+
path: 'files/thumbs/02.jpg',
1002+
mode: 'create',
1003+
sandboxPath: '/home/user/thumbs/02.jpg',
1004+
},
1005+
],
1006+
},
1007+
})
1008+
1009+
const response = await POST(req)
1010+
expect(response.status).toBe(200)
1011+
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
1012+
const call = mockWriteWorkspaceFileByPath.mock.calls[0]?.[0]
1013+
expect(call.target).toEqual(expect.objectContaining({ path: 'files/thumbs/02.jpg' }))
1014+
expect(call.inferredMimeType).toBe('image/jpeg')
1015+
expect(Buffer.from(call.buffer).equals(Buffer.from(jpegBase64, 'base64'))).toBe(true)
1016+
})
1017+
9351018
it("keeps the code's returned rows beside the sandbox export receipt", async () => {
9361019
envFlagsMock.isRemoteSandboxEnabled = true
9371020
const rows = [{ name: 'Ada' }, { name: 'Grace' }]

apps/sim/lib/function-execution/execute-request.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
MAX_SANDBOX_OUTPUT_BYTES,
7474
readTrustedSandboxOutputCost,
7575
} from '@/lib/execution/remote-sandbox/output-limits'
76+
import { isBinarySandboxPath } from '@/lib/execution/remote-sandbox/sandbox-encoding'
7677
import {
7778
MAX_BLOCK_MOUNTED_FILES,
7879
SANDBOX_OUTPUT_DIR,
@@ -1588,12 +1589,16 @@ async function maybeExportSandboxFileToWorkspace(args: {
15881589

15891590
const fileName = normalizeOutputWorkspaceFileName(outputPath)
15901591

1591-
const TEXT_MIMES = new Set(Object.values(FORMAT_TO_CONTENT_TYPE))
1592+
// Decode the way the sandbox read it (by path), never by guessing from the mime: a
1593+
// `.jpg` with no declared format resolved to the json text format and was stored as
1594+
// its base64 text (dev, 2026-09-03: thumbnails that opened as "raw text").
1595+
const isBinary = isBinarySandboxPath(outputSandboxPath)
15921596
const resolvedMimeType =
15931597
outputMimeType ||
1594-
FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, outputFormat)] ||
1598+
(isBinary
1599+
? getMimeTypeFromExtension(getFileExtension(fileName))
1600+
: FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, outputFormat)]) ||
15951601
'application/octet-stream'
1596-
const isBinary = !TEXT_MIMES.has(resolvedMimeType)
15971602
const outputBytes = Buffer.byteLength(exportedFileContent, isBinary ? 'base64' : 'utf-8')
15981603
if (outputBytes > MAX_SANDBOX_OUTPUT_BYTES) {
15991604
return exportFailure(
@@ -1766,11 +1771,14 @@ async function maybeExportSandboxFilesToWorkspace(args: {
17661771
}
17671772
const outputPath = file.formatPath ?? file.path
17681773
const fileName = normalizeOutputWorkspaceFileName(outputPath)
1774+
// Same rule as the single-file export: the sandbox path decides the encoding.
1775+
const isBinary = isBinarySandboxPath(sandboxPath)
17691776
const resolvedMimeType =
17701777
file.mimeType ||
1771-
FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, file.format)] ||
1778+
(isBinary
1779+
? getMimeTypeFromExtension(getFileExtension(fileName))
1780+
: FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, file.format)]) ||
17721781
'application/octet-stream'
1773-
const isBinary = !new Set(Object.values(FORMAT_TO_CONTENT_TYPE)).has(resolvedMimeType)
17741782
const size = Buffer.byteLength(content, isBinary ? 'base64' : 'utf-8')
17751783
totalOutputBytes += size
17761784
if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) {

0 commit comments

Comments
 (0)