Skip to content

Commit b674751

Browse files
icecrasher321claude
andcommitted
fix(sandbox): exempt caller-consumed streams from the output retention budget
A Pi agent turn emits one JSONL event per step and passes the 10 MB process output budget on an ordinary session, killing the run. The bytes were never a result: `handleChunk` parses every chunk as it arrives and keeps none of it, and the accumulated copy is only ever read back to build an error message. The budget bounds what Sim RETAINS, so a stream the caller consumes itself is exempt and only a 64 KB diagnostic tail is kept. The limit is unchanged for everything else. Gated per stream, not per command: a caller that streams stdout but not stderr still has stderr fully bounded. Both adapters gate on the handler's presence, so the calls that parse markers out of stdout (Pi's clone/prepare/push, which do not stream) keep full retention and full budgeting — the case daytona.ts already warns about. E2B's SDK still accumulates internally, so this bounds what Sim retains rather than the provider's peak; Daytona accumulates locally and is bounded outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2b35a3c commit b674751

4 files changed

Lines changed: 171 additions & 23 deletions

File tree

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import { E2B_MAX_SANDBOX_LIFETIME_MS, e2bProvider } from '@/lib/execution/remote
119119
import {
120120
MAX_SANDBOX_OUTPUT_BYTES,
121121
MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
122+
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES,
122123
} from '@/lib/execution/remote-sandbox/output-limits'
123124
import {
124125
PI_SANDBOX_MIN_LIFETIME_MS,
@@ -373,6 +374,70 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
373374
expect(provider === 'e2b' ? mockE2BKill : mockDelete).toHaveBeenCalledTimes(1)
374375
})
375376

377+
it('exempts a caller-consumed stream from the budget and keeps a diagnostic tail', async () => {
378+
// A Pi agent turn streams one JSONL event per step and routinely passes the retention budget
379+
// while producing no oversized result — the caller parses every chunk and keeps none of it.
380+
const oversized = 'x'.repeat(MAX_SANDBOX_PROCESS_OUTPUT_BYTES + 1024)
381+
if (provider === 'e2b') {
382+
mockE2BCommandsRun.mockImplementationOnce(async (_cmd: string, options: any) => {
383+
options.onStdout(`${oversized}TAIL_MARKER`)
384+
return { stdout: `${oversized}TAIL_MARKER`, stderr: '', exitCode: 0 }
385+
})
386+
} else {
387+
mockGetSessionCommandLogs.mockImplementationOnce(
388+
async (_sessionId: string, _commandId: string, onStdout: (chunk: string) => void) => {
389+
onStdout(`${oversized}TAIL_MARKER`)
390+
}
391+
)
392+
mockGetSessionCommand.mockResolvedValue({ exitCode: 0 })
393+
}
394+
395+
let streamedBytes = 0
396+
const result = await withPiSandbox({}, (runner) =>
397+
runner.run('pi run', {
398+
timeoutMs: 1000,
399+
onStdout: (chunk) => {
400+
streamedBytes += chunk.length
401+
},
402+
})
403+
)
404+
405+
// Delivered in full to the caller...
406+
expect(streamedBytes).toBeGreaterThan(MAX_SANDBOX_PROCESS_OUTPUT_BYTES)
407+
expect(result.exitCode).toBe(0)
408+
// ...but only the tail is retained. Reaching exitCode 0 at all is the point: before the
409+
// exemption this threw `sandbox_output_limit_exceeded` and killed the sandbox mid-run.
410+
expect(result.stdout).toContain('TAIL_MARKER')
411+
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(
412+
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES * 2
413+
)
414+
})
415+
416+
it('still bounds a stream the caller does not consume', async () => {
417+
const oversized = 'x'.repeat(MAX_SANDBOX_PROCESS_OUTPUT_BYTES + 1)
418+
if (provider === 'e2b') {
419+
mockE2BCommandsRun.mockImplementationOnce(async (_cmd: string, options: any) => {
420+
options.onStderr(oversized)
421+
})
422+
} else {
423+
mockGetSessionCommandLogs.mockImplementationOnce(
424+
async (
425+
_sessionId: string,
426+
_commandId: string,
427+
_onStdout: (chunk: string) => void,
428+
onStderr: (chunk: string) => void
429+
) => {
430+
onStderr(oversized)
431+
}
432+
)
433+
}
434+
435+
// stdout is streamed, stderr is not — the exemption is per stream, not per command.
436+
await expect(
437+
withPiSandbox({}, (runner) => runner.run('pi run', { timeoutMs: 1000, onStdout: () => {} }))
438+
).rejects.toMatchObject({ code: 'sandbox_output_limit_exceeded', outputKind: 'process' })
439+
})
440+
376441
it('normalizes execution errors to the same shape', async () => {
377442
stubShellCommand(provider, '', 'Traceback...\nValueError: boom', 1)
378443

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/lib/core/utils/stream-limits'
1414
import { CodeLanguage } from '@/lib/execution/languages'
1515
import {
16+
appendStreamedSandboxOutput,
1617
isSandboxOutputLimitError,
1718
MAX_SANDBOX_OUTPUT_BYTES,
1819
MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
@@ -246,6 +247,11 @@ class DaytonaSandboxHandle implements SandboxHandle {
246247
const outputBudget = new SandboxProcessOutputBudget(
247248
options.maxOutputBytes ?? MAX_SANDBOX_PROCESS_OUTPUT_BYTES
248249
)
250+
// Matches the E2B adapter: the budget bounds what Sim retains, so a stream the caller consumes
251+
// itself is exempt and only a diagnostic tail is kept. Per stream, so a caller that streams
252+
// stdout but not stderr still has stderr fully bounded. The failover must not change behavior.
253+
const retainStdout = options.onStdout === undefined
254+
const retainStderr = options.onStderr === undefined
249255
try {
250256
await this.sandbox.process.createSession(sessionId)
251257
sessionCreated = true
@@ -282,14 +288,17 @@ class DaytonaSandboxHandle implements SandboxHandle {
282288
const appendOutput = (
283289
chunk: string,
284290
append: (value: string) => void,
291+
retain: boolean,
285292
callback?: (value: string) => void
286293
) => {
287-
try {
288-
outputBudget.add(chunk)
289-
} catch {
290-
void this.kill().catch(() => {})
291-
resolveOutputLimit()
292-
return
294+
if (retain) {
295+
try {
296+
outputBudget.add(chunk)
297+
} catch {
298+
void this.kill().catch(() => {})
299+
resolveOutputLimit()
300+
return
301+
}
293302
}
294303
append(chunk)
295304
callback?.(chunk)
@@ -302,17 +311,19 @@ class DaytonaSandboxHandle implements SandboxHandle {
302311
appendOutput(
303312
chunk,
304313
(value) => {
305-
stdout += value
314+
stdout = retainStdout ? stdout + value : appendStreamedSandboxOutput(stdout, value)
306315
},
316+
retainStdout,
307317
options.onStdout
308318
)
309319
},
310320
(chunk: string) => {
311321
appendOutput(
312322
chunk,
313323
(value) => {
314-
stderr += value
324+
stderr = retainStderr ? stderr + value : appendStreamedSandboxOutput(stderr, value)
315325
},
326+
retainStderr,
316327
options.onStderr
317328
)
318329
}

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

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
SandboxOutputFileError,
3737
SandboxOutputLimitError,
3838
SandboxProcessOutputBudget,
39+
tailStreamedSandboxOutput,
3940
} from '@/lib/execution/remote-sandbox/output-limits'
4041
import {
4142
quoteDependency,
@@ -389,12 +390,19 @@ class E2BSandboxHandle implements SandboxHandle {
389390
const outputBudget = new SandboxProcessOutputBudget(
390391
options.maxOutputBytes ?? MAX_SANDBOX_PROCESS_OUTPUT_BYTES
391392
)
392-
const guardOutput = (value: string, callback?: (chunk: string) => void) => {
393-
try {
394-
outputBudget.add(value)
395-
} catch (error) {
396-
void this.kill().catch(() => {})
397-
throw error
393+
// The budget bounds what Sim RETAINS, so a stream the caller consumes itself is exempt: it has
394+
// already been delivered chunk by chunk, and only a diagnostic tail is kept. Per stream, not
395+
// per command — a caller that streams stdout but not stderr still has stderr fully bounded.
396+
const retainStdout = options.onStdout === undefined
397+
const retainStderr = options.onStderr === undefined
398+
const guardOutput = (value: string, retain: boolean, callback?: (chunk: string) => void) => {
399+
if (retain) {
400+
try {
401+
outputBudget.add(value)
402+
} catch (error) {
403+
void this.kill().catch(() => {})
404+
throw error
405+
}
398406
}
399407
callback?.(value)
400408
}
@@ -405,11 +413,18 @@ class E2BSandboxHandle implements SandboxHandle {
405413
timeoutMs: e2bTimeoutMs(options.timeoutMs),
406414
...(options.signal ? { signal: options.signal } : {}),
407415
...(options.rootUser ? { user: 'root' as const } : {}),
408-
onStdout: (chunk) => guardOutput(chunk, options.onStdout),
409-
onStderr: (chunk) => guardOutput(chunk, options.onStderr),
416+
onStdout: (chunk) => guardOutput(chunk, retainStdout, options.onStdout),
417+
onStderr: (chunk) => guardOutput(chunk, retainStderr, options.onStderr),
410418
})
411-
assertSandboxProcessOutputWithinLimit([result.stdout, result.stderr], options.maxOutputBytes)
412-
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }
419+
assertSandboxProcessOutputWithinLimit(
420+
[retainStdout ? result.stdout : undefined, retainStderr ? result.stderr : undefined],
421+
options.maxOutputBytes
422+
)
423+
return {
424+
stdout: retainStdout ? result.stdout : tailStreamedSandboxOutput(result.stdout),
425+
stderr: retainStderr ? result.stderr : tailStreamedSandboxOutput(result.stderr),
426+
exitCode: result.exitCode,
427+
}
413428
} catch (error) {
414429
if (outputBudget.error) throw outputBudget.error
415430
if (reachedE2BProviderLimit(error, this.providerLimitAtMs, options.signal)) {
@@ -432,21 +447,33 @@ class E2BSandboxHandle implements SandboxHandle {
432447
) {
433448
throw error
434449
}
450+
// The SDK throws on a non-zero exit, so this is the ordinary path for a failing streamed
451+
// command — the same retention exemption has to apply here or a failing Pi turn still trips
452+
// the budget on output the caller already consumed. `message` never streams, so it is always
453+
// retained and billed.
435454
assertSandboxProcessOutputWithinLimit(
436-
[failure.stdout, failure.stderr, failure.message],
455+
[
456+
retainStdout ? failure.stdout : undefined,
457+
retainStderr ? failure.stderr : undefined,
458+
failure.message,
459+
],
437460
options.maxOutputBytes
438461
)
462+
const tailIfStreamed = (value: string | undefined, retain: boolean) =>
463+
retain || value === undefined ? value : tailStreamedSandboxOutput(value)
464+
const failureStdout = tailIfStreamed(failure.stdout, retainStdout)
465+
const failureStderr = tailIfStreamed(failure.stderr, retainStderr)
439466
if (isE2BExecutionTimeout(error)) {
440467
return {
441-
stdout: failure.stdout ?? '',
442-
stderr: failure.stderr ?? failure.message ?? '',
468+
stdout: failureStdout ?? '',
469+
stderr: failureStderr ?? failure.message ?? '',
443470
exitCode: 124,
444471
timedOut: true,
445472
}
446473
}
447474
return {
448-
stdout: failure.stdout ?? '',
449-
stderr: failure.stderr ?? failure.message ?? getErrorMessage(error),
475+
stdout: failureStdout ?? '',
476+
stderr: failureStderr ?? failure.message ?? getErrorMessage(error),
450477
exitCode: failure.exitCode ?? 1,
451478
}
452479
}

apps/sim/lib/execution/remote-sandbox/output-limits.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,51 @@ export const MAX_SANDBOX_OUTPUT_BYTES = 50 * 1024 * 1024
77
*/
88
export const MAX_SANDBOX_PROCESS_OUTPUT_BYTES = 10 * 1024 * 1024
99

10+
/**
11+
* Diagnostic tail kept from a stream the caller consumed itself.
12+
*
13+
* A caller that passes `onStdout`/`onStderr` takes delivery of every chunk as it arrives, so the
14+
* adapter's accumulated copy is never the result — it is only ever read back to explain a failure.
15+
* Billing that copy to the retention budget kills runs whose live stream is legitimately long while
16+
* producing no oversized result: a Pi agent turn emits one JSONL event per step and passes 10 MB on
17+
* an ordinary session, even though the caller has already parsed every event and keeps none of it.
18+
*/
19+
export const MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES = 64 * 1024
20+
21+
const STREAMED_OUTPUT_TRUNCATION_NOTE =
22+
'[earlier output truncated — it was streamed to the caller]\n'
23+
24+
/**
25+
* Keeps the last {@link MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES} of a streamed output. The cut is
26+
* advanced past any UTF-8 continuation bytes so the tail starts on a code-point boundary rather
27+
* than decoding to replacement characters.
28+
*/
29+
export function tailStreamedSandboxOutput(
30+
value: string | undefined,
31+
limitBytes = MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES
32+
): string {
33+
if (!value) return ''
34+
const buffer = Buffer.from(value, 'utf8')
35+
if (buffer.length <= limitBytes) return value
36+
37+
let start = buffer.length - limitBytes
38+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1
39+
return `${STREAMED_OUTPUT_TRUNCATION_NOTE}${buffer.subarray(start).toString('utf8')}`
40+
}
41+
42+
/**
43+
* Appends to a streamed-output accumulator, collapsing it back to the diagnostic tail once it grows
44+
* past twice that tail. Truncating on every chunk would be quadratic over a long stream. The
45+
* threshold compares UTF-16 length rather than bytes because it only decides *when* to collapse —
46+
* {@link tailStreamedSandboxOutput} does the byte-exact cut.
47+
*/
48+
export function appendStreamedSandboxOutput(current: string, chunk: string): string {
49+
const next = current + chunk
50+
return next.length > MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES * 2
51+
? tailStreamedSandboxOutput(next)
52+
: next
53+
}
54+
1055
export const SANDBOX_OUTPUT_LIMIT_CODE = 'sandbox_output_limit_exceeded' as const
1156
export const SANDBOX_OUTPUT_FILE_INVALID_CODE = 'sandbox_output_file_invalid' as const
1257

0 commit comments

Comments
 (0)