Skip to content

Commit 553849a

Browse files
fix(sandbox): spend each file ceiling once across every source (#7288)
Two ceilings on a Function run's sandbox files were charged per source rather than per execution. Mounts: planUserFileMounts assigned a path per element, so one storage key named by two sources became two mounts. `files` is `user-or-llm` and deduped nowhere, so a model repeating an id — or naming a file the code also references with `<block.file.path>` — produced a duplicate that cost a presign, a second transfer of identical bytes, and a second charge against both the byte budget and the 20-file mount ceiling, either of which then refuses a request that fits. Collapse by storage key, first occurrence wins. The contract already requires a non-empty key, so there is no keyless case to carry. Exports: MAX_SANDBOX_OUTPUT_FILES is documented as what one execution may export "whether declared by path or discovered by harvesting", and collectExportedFiles already runs the byte ceiling that way. The count ceiling did not, so a request declaring paths and harvesting a directory could export 20 of each. Count declared and discovered together, with a declared path inside the directory dropped from the discovered set so it is not billed on both sides. With no declared paths — every call execute-request makes, since it sets outputSandboxDir only when nothing declares a sandboxPath — the check and its message are unchanged. The resolver's marker reuse is no longer what keeps a twice-referenced file to one mount; its comment said otherwise. Fixture keys in sandbox-mounts.test.ts were identical across files the tests meant to be distinct; they now differ, which is what those tests always claimed to set up. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent cd42dce commit 553849a

5 files changed

Lines changed: 126 additions & 31 deletions

File tree

apps/sim/executor/variables/resolver.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,10 @@ export class VariableResolver {
694694
return null
695695
}
696696

697-
// Reuse an existing marker for the same file so referencing one path twice
698-
// mounts it once, rather than transferring a second copy under a
699-
// collision-suffixed name and spending the mount budget twice.
697+
// Reuse the marker already standing for this file so a path referenced twice
698+
// costs one context variable rather than two. What keeps it to one mount is
699+
// `planUserFileMounts`, which collapses by storage key across every source —
700+
// this only keeps the duplicate out of the request body.
700701
const existing = Object.entries(contextVarAccumulator).find(
701702
([, value]) => isSandboxFileMountRef(value) && value.file.key === file.key
702703
)

apps/sim/lib/execution/remote-sandbox/conformance.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,59 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
868868
).rejects.toThrow(/over the 20-file export limit/)
869869
})
870870

871+
it('spends one file ceiling across declared and harvested outputs', async () => {
872+
// The limit is what an execution exports, not what one directory holds, so a
873+
// request that both declares and harvests cannot take 20 of each.
874+
stubCodeRun(provider, `__SIM_RESULT__=${JSON.stringify('done')}`)
875+
stubOutputFileSizes(provider, 1, 1)
876+
stubOutputDirListing(
877+
Array.from({ length: MAX_SANDBOX_OUTPUT_FILES - 1 }, (_, index) => ({
878+
path: `/tmp/sim/outputs/file-${index}.txt`,
879+
size: 1,
880+
}))
881+
)
882+
883+
await expect(
884+
executeInSandbox({
885+
code: 'x',
886+
language: CodeLanguage.Python,
887+
timeoutMs: 1000,
888+
outputSandboxPaths: ['/out/first.txt', '/out/second.txt'],
889+
outputSandboxDir: '/tmp/sim/outputs',
890+
})
891+
).rejects.toThrow(/produced 21 files .* over the 20-file export limit/)
892+
})
893+
894+
it('does not charge a declared path inside the harvest directory to the ceiling twice', async () => {
895+
// The directory holds exactly the limit and the request names one of those
896+
// files. Charging it on both sides would refuse a run exporting 20 files.
897+
stubCodeRun(provider, `__SIM_RESULT__=${JSON.stringify('done')}`)
898+
// One inspection for the declared path, then one per file actually read.
899+
stubOutputFileSizes(provider, ...Array.from({ length: MAX_SANDBOX_OUTPUT_FILES + 1 }, () => 1))
900+
stubOutputDirListing(
901+
Array.from({ length: MAX_SANDBOX_OUTPUT_FILES }, (_, index) => ({
902+
path: `/tmp/sim/outputs/file-${index}.txt`,
903+
size: 1,
904+
}))
905+
)
906+
for (let index = 0; index < MAX_SANDBOX_OUTPUT_FILES; index += 1) {
907+
stubOutputFileRead(provider, 'x')
908+
}
909+
910+
const result = await executeInSandbox({
911+
code: 'x',
912+
language: CodeLanguage.Python,
913+
timeoutMs: 1000,
914+
outputSandboxPath: '/tmp/sim/outputs/file-0.txt',
915+
outputSandboxDir: '/tmp/sim/outputs',
916+
})
917+
918+
// Exported once as a declared path, rather than a second time as a harvest.
919+
expect(Object.keys(result.exportedFiles ?? {})).toEqual(['/tmp/sim/outputs/file-0.txt'])
920+
expect(result.collectedFiles).toHaveLength(MAX_SANDBOX_OUTPUT_FILES - 1)
921+
expect(result.collectedFiles?.map((file) => file.relativePath)).not.toContain('file-0.txt')
922+
})
923+
871924
it('does not list the output directory when no harvest was requested', async () => {
872925
stubCodeRun(provider, `__SIM_RESULT__=${JSON.stringify('done')}`)
873926

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -554,10 +554,16 @@ function requestedOutputSandboxPaths(req: {
554554
* too many files, or nesting past what the listing reaches — before a single
555555
* byte is read. Sorted so a multi-file result is stable run to run rather than
556556
* inheriting whatever order the provider happened to return.
557+
*
558+
* `declaredPaths` are the files the request already named. One sitting inside the
559+
* directory is dropped rather than harvested a second time, and the rest count
560+
* toward the ceiling: the limit is what one execution exports, not what one
561+
* directory holds, so declaring and harvesting cannot spend it twice.
557562
*/
558563
async function listOutputDirectoryFiles(
559564
sandbox: SandboxHandle,
560565
outputSandboxDir: string,
566+
declaredPaths: ReadonlySet<string>,
561567
signal: AbortSignal
562568
): Promise<SandboxDirectoryEntry[]> {
563569
let listed: SandboxDirectoryEntry[]
@@ -593,9 +599,10 @@ async function listOutputDirectoryFiles(
593599
)
594600
}
595601

596-
const files = entries.filter((entry) => entry.kind === 'file')
597-
if (files.length > MAX_SANDBOX_OUTPUT_FILES) {
598-
throw new SandboxOutputFileCountError(files.length, outputSandboxDir)
602+
const files = entries.filter((entry) => entry.kind === 'file' && !declaredPaths.has(entry.path))
603+
const exported = declaredPaths.size + files.length
604+
if (exported > MAX_SANDBOX_OUTPUT_FILES) {
605+
throw new SandboxOutputFileCountError(exported, outputSandboxDir)
599606
}
600607
return files.sort((a, b) => a.path.localeCompare(b.path))
601608
}
@@ -640,16 +647,14 @@ async function collectExportedFiles(
640647
}
641648

642649
// Sized into the same running total as the declared paths, so an execution
643-
// cannot spend the ceiling twice by both declaring and harvesting. A declared
644-
// path that happens to sit inside the harvest directory is dropped from the
645-
// discovered set rather than counted again — double-billing it would reject a
646-
// single output larger than half the ceiling as oversized.
650+
// cannot spend the byte ceiling twice by both declaring and harvesting. The
651+
// listing applies the same rule to the file-count ceiling and drops a declared
652+
// path that happens to sit inside the harvest directory — double-billing it
653+
// would reject a single output larger than half the ceiling as oversized.
647654
const declaredPaths = new Set(readablePaths)
648-
const discovered = (
649-
req.outputSandboxDir
650-
? await listOutputDirectoryFiles(sandbox, req.outputSandboxDir, options.signal)
651-
: []
652-
).filter((entry) => !declaredPaths.has(entry.path))
655+
const discovered = req.outputSandboxDir
656+
? await listOutputDirectoryFiles(sandbox, req.outputSandboxDir, declaredPaths, options.signal)
657+
: []
653658
for (const entry of discovered) {
654659
totalOutputBytes += entry.size
655660
if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) {

apps/sim/lib/function-execution/sandbox-mounts.test.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ describe('planUserFileMounts', () => {
8888
it('cannot be escaped by a traversal in the file name', () => {
8989
const planned = planUserFileMounts([
9090
executionFile({ name: '../../etc/passwd' }),
91-
executionFile({ id: 'file_2', name: '..' }),
91+
executionFile({ id: 'file_2', key: 'execution/other', name: '..' }),
9292
])
9393

9494
for (const { mountPath } of planned) {
@@ -100,9 +100,9 @@ describe('planUserFileMounts', () => {
100100

101101
it('suffixes colliding names so neither file is silently overwritten', () => {
102102
const planned = planUserFileMounts([
103-
executionFile({ id: 'file_1', name: 'report.csv' }),
104-
executionFile({ id: 'file_2', name: 'report.csv' }),
105-
executionFile({ id: 'file_3', name: 'report.csv' }),
103+
executionFile({ id: 'file_1', key: 'execution/a/report.csv', name: 'report.csv' }),
104+
executionFile({ id: 'file_2', key: 'execution/b/report.csv', name: 'report.csv' }),
105+
executionFile({ id: 'file_3', key: 'execution/c/report.csv', name: 'report.csv' }),
106106
])
107107

108108
expect(planned.map((entry) => entry.mountPath)).toEqual([
@@ -111,6 +111,24 @@ describe('planUserFileMounts', () => {
111111
'/tmp/sim/inputs/report-3.csv',
112112
])
113113
})
114+
115+
it('mounts one storage key once however many sources named it', () => {
116+
// A caller listing the same file twice, and a `<block.file.path>` marker for
117+
// a file the caller also passed explicitly, both land in one list here. A
118+
// second copy of identical bytes costs a presign and a duplicate transfer,
119+
// and charges the byte budget and the 20-file ceiling twice over.
120+
const planned = planUserFileMounts([
121+
executionFile({ id: 'file_1', name: 'report.csv' }),
122+
executionFile({ id: 'file_1_again', name: 'report.csv' }),
123+
executionFile({ id: 'file_2', name: 'renamed.csv' }),
124+
workspaceFile(),
125+
])
126+
127+
expect(planned.map((entry) => entry.mountPath)).toEqual([
128+
'/tmp/sim/inputs/report.csv',
129+
'/tmp/sim/inputs/brief.pdf',
130+
])
131+
})
114132
})
115133

116134
describe('resolveUserFileMounts', () => {
@@ -193,14 +211,16 @@ describe('resolveUserFileMounts', () => {
193211

194212
await expect(
195213
resolveUserFileMounts({
196-
planned: planUserFileMounts([
197-
executionFile({ id: 'a', name: 'a.bin', size: 9 * 1024 * 1024 }),
198-
executionFile({ id: 'b', name: 'b.bin', size: 9 * 1024 * 1024 }),
199-
executionFile({ id: 'c', name: 'c.bin', size: 9 * 1024 * 1024 }),
200-
executionFile({ id: 'd', name: 'd.bin', size: 9 * 1024 * 1024 }),
201-
executionFile({ id: 'e', name: 'e.bin', size: 9 * 1024 * 1024 }),
202-
executionFile({ id: 'f', name: 'f.bin', size: 9 * 1024 * 1024 }),
203-
]),
214+
planned: planUserFileMounts(
215+
['a', 'b', 'c', 'd', 'e', 'f'].map((id) =>
216+
executionFile({
217+
id,
218+
key: `execution/${WORKSPACE_ID}/${WORKFLOW_ID}/${EXECUTION_ID}/${id}/${id}.bin`,
219+
name: `${id}.bin`,
220+
size: 9 * 1024 * 1024,
221+
})
222+
)
223+
),
204224
context: executionContext,
205225
})
206226
).rejects.toThrow(/total mount limit/)

apps/sim/lib/function-execution/sandbox-mounts.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,16 +227,32 @@ function uniqueMountFileName(name: string, used: Set<string>): string {
227227
* Assigns each file a deterministic mount path. Pure and I/O-free, so a caller
228228
* can decide whether an execution needs a sandbox filesystem before spending a
229229
* presign or a byte of transfer on a request that may still be refused.
230+
*
231+
* A storage key mounts once. The same object arrives from independent sources —
232+
* a caller listing it twice, or listing one the code also asked for with
233+
* `<block.file.path>` — and a second copy of identical bytes costs a presign, a
234+
* duplicate transfer, and a second charge against both the byte budget and the
235+
* per-request file ceiling. First occurrence wins, so the name listed first is
236+
* the one the code sees.
230237
*/
231238
export function planUserFileMounts(
232239
files: readonly UserFile[],
233240
mountDir: string = SANDBOX_INPUT_DIR
234241
): PlannedUserFileMount[] {
235242
const used = new Set<string>()
236-
return files.map((userFile) => ({
237-
userFile,
238-
mountPath: `${mountDir}/${uniqueMountFileName(userFile.name, used)}`,
239-
}))
243+
const mountedKeys = new Set<string>()
244+
const planned: PlannedUserFileMount[] = []
245+
246+
for (const userFile of files) {
247+
if (mountedKeys.has(userFile.key)) continue
248+
mountedKeys.add(userFile.key)
249+
planned.push({
250+
userFile,
251+
mountPath: `${mountDir}/${uniqueMountFileName(userFile.name, used)}`,
252+
})
253+
}
254+
255+
return planned
240256
}
241257

242258
/**

0 commit comments

Comments
 (0)