Skip to content

Commit a9ac0ae

Browse files
icecrasher321claude
andcommitted
fix(sandbox): spend each file ceiling once across every source
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. The resolver already reuses a marker for a file the code references twice, but nothing collapsed a caller's `files` list against itself or against those markers — and `files` is `user-or-llm`, so a model repeating an id is the ordinary case. The duplicate 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. Key on the storage key, first occurrence wins; keyless files stay separate since an empty key identifies nothing. Exports: MAX_SANDBOX_OUTPUT_FILES is documented as what one execution may export "whether declared by path or discovered by harvesting", and the byte ceiling is already shared and dedup-aware across the two. The count ceiling was not — a request declaring paths and harvesting a directory could export 20 of each. Check the harvest against what the declared paths leave, and drop a declared path inside the directory from the discovered set so it is not billed on both sides. Unreachable from execute-request today, which sets outputSandboxDir only when nothing declares a sandboxPath, but the layer is caller-agnostic and already commits to the combined rule for bytes. 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 a9ac0ae

4 files changed

Lines changed: 143 additions & 28 deletions

File tree

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(/over the 18-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 still
560+
* consume the ceiling: the limit is what one execution exports, not what one
561+
* directory holds, so declaring and harvesting together 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 remaining = Math.max(0, MAX_SANDBOX_OUTPUT_FILES - declaredPaths.size)
604+
if (files.length > remaining) {
605+
throw new SandboxOutputFileCountError(files.length, outputSandboxDir, remaining)
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: 46 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,38 @@ 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+
})
132+
133+
it('keeps keyless files apart rather than collapsing them onto each other', () => {
134+
// An empty key identifies no stored object, so two of them are not evidence
135+
// of the same file.
136+
const planned = planUserFileMounts([
137+
executionFile({ id: 'file_1', key: '', name: 'a.csv' }),
138+
executionFile({ id: 'file_2', key: '', name: 'b.csv' }),
139+
])
140+
141+
expect(planned.map((entry) => entry.mountPath)).toEqual([
142+
'/tmp/sim/inputs/a.csv',
143+
'/tmp/sim/inputs/b.csv',
144+
])
145+
})
114146
})
115147

116148
describe('resolveUserFileMounts', () => {
@@ -193,14 +225,16 @@ describe('resolveUserFileMounts', () => {
193225

194226
await expect(
195227
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-
]),
228+
planned: planUserFileMounts(
229+
['a', 'b', 'c', 'd', 'e', 'f'].map((id) =>
230+
executionFile({
231+
id,
232+
key: `execution/${WORKSPACE_ID}/${WORKFLOW_ID}/${EXECUTION_ID}/${id}/${id}.bin`,
233+
name: `${id}.bin`,
234+
size: 9 * 1024 * 1024,
235+
})
236+
)
237+
),
204238
context: executionContext,
205239
})
206240
).rejects.toThrow(/total mount limit/)

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,16 +227,39 @@ 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+
* One storage key mounts once. The same object reaches here from independent
232+
* sources — a caller listing it twice, or listing one the code also asked for
233+
* with `<block.file.path>` — and a second copy of identical bytes buys nothing
234+
* while costing a presign, a duplicate transfer, and a second charge against
235+
* both the byte budget and the per-request file ceiling, either of which can
236+
* refuse a request that fits. First occurrence wins, so the name listed first is
237+
* the one the code sees.
230238
*/
231239
export function planUserFileMounts(
232240
files: readonly UserFile[],
233241
mountDir: string = SANDBOX_INPUT_DIR
234242
): PlannedUserFileMount[] {
235243
const used = new Set<string>()
236-
return files.map((userFile) => ({
237-
userFile,
238-
mountPath: `${mountDir}/${uniqueMountFileName(userFile.name, used)}`,
239-
}))
244+
const mountedKeys = new Set<string>()
245+
const planned: PlannedUserFileMount[] = []
246+
247+
for (const userFile of files) {
248+
// Keyed on the storage key because that is the stored object's identity, and
249+
// what the caller later looks mount paths up by. An empty key identifies
250+
// nothing, so those files stay separate rather than collapsing onto whichever
251+
// one came first.
252+
if (userFile.key) {
253+
if (mountedKeys.has(userFile.key)) continue
254+
mountedKeys.add(userFile.key)
255+
}
256+
planned.push({
257+
userFile,
258+
mountPath: `${mountDir}/${uniqueMountFileName(userFile.name, used)}`,
259+
})
260+
}
261+
262+
return planned
240263
}
241264

242265
/**

0 commit comments

Comments
 (0)