Skip to content

Commit f340ad9

Browse files
improvement(sandbox): exempt caller-consumed streams from the output retention budget (#6353)
* 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> * test(sandbox): drop explicit any from the new conformance stream mocks The two new E2B mocks annotated their arguments as `any`, which both violates the repo's no-`any` rule and defeats the point of a mock: an invalid SDK shape would type-check. Matches the sibling mock a few lines above (`async (_code, options) =>`) and infers from the `vi.fn()` signature instead of naming a type, so the mock stays bound to whatever the adapter actually calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sandbox): cut Daytona's retained tail to the same bound as E2B `appendStreamedSandboxOutput` deliberately lets the accumulator grow to twice the tail before collapsing, so a single re-cut is amortized across chunks rather than paid on every one. That leaves it anywhere inside that band when the stream ends. E2B tails the value it returns, Daytona returned the accumulator as-is, so a stream finishing between one and two tails came back roughly 96 KB on Daytona and 64 KB on E2B. The two adapters must agree — a divergence here surfaces as changed behavior during a failover, which is the one moment nobody wants surprises. Daytona now takes the same final cut on every return path. The conformance test that should have caught this asserted the bound as `tail * 2`, which is satisfied by both the correct and the incorrect value. It now asserts the tail plus the truncation note, and a second case exercises the band between one and two tails where the two providers could disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent de02bc6 commit f340ad9

4 files changed

Lines changed: 229 additions & 29 deletions

File tree

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

Lines changed: 99 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,
@@ -293,6 +294,9 @@ beforeEach(() => {
293294
mockGetSessionCommand.mockResolvedValue({ exitCode: 0 })
294295
})
295296

297+
/** Headroom for the note `tailStreamedSandboxOutput` prepends when it truncates. */
298+
const TRUNCATION_NOTE_ALLOWANCE_BYTES = 256
299+
296300
describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
297301
beforeEach(() => useProvider(provider))
298302

@@ -373,6 +377,101 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
373377
expect(provider === 'e2b' ? mockE2BKill : mockDelete).toHaveBeenCalledTimes(1)
374378
})
375379

380+
it('exempts a caller-consumed stream from the budget and keeps a diagnostic tail', async () => {
381+
// A Pi agent turn streams one JSONL event per step and routinely passes the retention budget
382+
// while producing no oversized result — the caller parses every chunk and keeps none of it.
383+
const oversized = 'x'.repeat(MAX_SANDBOX_PROCESS_OUTPUT_BYTES + 1024)
384+
if (provider === 'e2b') {
385+
mockE2BCommandsRun.mockImplementationOnce(async (_cmd, options) => {
386+
options.onStdout(`${oversized}TAIL_MARKER`)
387+
return { stdout: `${oversized}TAIL_MARKER`, stderr: '', exitCode: 0 }
388+
})
389+
} else {
390+
mockGetSessionCommandLogs.mockImplementationOnce(
391+
async (_sessionId: string, _commandId: string, onStdout: (chunk: string) => void) => {
392+
onStdout(`${oversized}TAIL_MARKER`)
393+
}
394+
)
395+
mockGetSessionCommand.mockResolvedValue({ exitCode: 0 })
396+
}
397+
398+
let streamedBytes = 0
399+
const result = await withPiSandbox({}, (runner) =>
400+
runner.run('pi run', {
401+
timeoutMs: 1000,
402+
onStdout: (chunk) => {
403+
streamedBytes += chunk.length
404+
},
405+
})
406+
)
407+
408+
// Delivered in full to the caller...
409+
expect(streamedBytes).toBeGreaterThan(MAX_SANDBOX_PROCESS_OUTPUT_BYTES)
410+
expect(result.exitCode).toBe(0)
411+
// ...but only the tail is retained. Reaching exitCode 0 at all is the point: before the
412+
// exemption this threw `sandbox_output_limit_exceeded` and killed the sandbox mid-run.
413+
expect(result.stdout).toContain('TAIL_MARKER')
414+
// The tail plus its truncation note, NOT a multiple of it. A looser bound here passes on both
415+
// providers even when one returns twice as much as the other, which is the divergence this
416+
// pair exists to prevent.
417+
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(
418+
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES + TRUNCATION_NOTE_ALLOWANCE_BYTES
419+
)
420+
})
421+
422+
it('cuts the retained tail identically when a stream ends between one and two tails', async () => {
423+
// The Daytona appender only collapses once the accumulator passes twice the tail, so a stream
424+
// finishing inside that band is the case where the two providers can disagree.
425+
const midBand = 'y'.repeat(Math.floor(MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES * 1.5))
426+
if (provider === 'e2b') {
427+
mockE2BCommandsRun.mockImplementationOnce(async (_cmd, options) => {
428+
options.onStdout?.(midBand)
429+
return { stdout: midBand, stderr: '', exitCode: 0 }
430+
})
431+
} else {
432+
mockGetSessionCommandLogs.mockImplementationOnce(
433+
async (_sessionId: string, _commandId: string, onStdout: (chunk: string) => void) => {
434+
onStdout(midBand)
435+
}
436+
)
437+
mockGetSessionCommand.mockResolvedValue({ exitCode: 0 })
438+
}
439+
440+
const result = await withPiSandbox({}, (runner) =>
441+
runner.run('pi run', { timeoutMs: 1000, onStdout: () => {} })
442+
)
443+
444+
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(
445+
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES + TRUNCATION_NOTE_ALLOWANCE_BYTES
446+
)
447+
expect(Buffer.byteLength(result.stdout)).toBeLessThan(Buffer.byteLength(midBand))
448+
})
449+
450+
it('still bounds a stream the caller does not consume', async () => {
451+
const oversized = 'x'.repeat(MAX_SANDBOX_PROCESS_OUTPUT_BYTES + 1)
452+
if (provider === 'e2b') {
453+
mockE2BCommandsRun.mockImplementationOnce(async (_cmd, options) => {
454+
options.onStderr(oversized)
455+
})
456+
} else {
457+
mockGetSessionCommandLogs.mockImplementationOnce(
458+
async (
459+
_sessionId: string,
460+
_commandId: string,
461+
_onStdout: (chunk: string) => void,
462+
onStderr: (chunk: string) => void
463+
) => {
464+
onStderr(oversized)
465+
}
466+
)
467+
}
468+
469+
// stdout is streamed, stderr is not — the exemption is per stream, not per command.
470+
await expect(
471+
withPiSandbox({}, (runner) => runner.run('pi run', { timeoutMs: 1000, onStdout: () => {} }))
472+
).rejects.toMatchObject({ code: 'sandbox_output_limit_exceeded', outputKind: 'process' })
473+
})
474+
376475
it('normalizes execution errors to the same shape', async () => {
377476
stubShellCommand(provider, '', 'Traceback...\nValueError: boom', 1)
378477

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

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ 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,
1920
SandboxOutputFileError,
2021
SandboxOutputLimitError,
2122
SandboxProcessOutputBudget,
23+
tailStreamedSandboxOutput,
2224
} from '@/lib/execution/remote-sandbox/output-limits'
2325
import type {
2426
CreateSandboxOptions,
@@ -246,6 +248,18 @@ class DaytonaSandboxHandle implements SandboxHandle {
246248
const outputBudget = new SandboxProcessOutputBudget(
247249
options.maxOutputBytes ?? MAX_SANDBOX_PROCESS_OUTPUT_BYTES
248250
)
251+
// Matches the E2B adapter: the budget bounds what Sim retains, so a stream the caller consumes
252+
// itself is exempt and only a diagnostic tail is kept. Per stream, so a caller that streams
253+
// stdout but not stderr still has stderr fully bounded. The failover must not change behavior.
254+
const retainStdout = options.onStdout === undefined
255+
const retainStderr = options.onStderr === undefined
256+
// The appender keeps the accumulator under twice the tail so it is not re-cut on every chunk,
257+
// which leaves it anywhere in that band when the stream ends. E2B tails the value it returns,
258+
// so the final cut has to happen here too or a stream finishing between one and two tails comes
259+
// back longer on Daytona than on E2B — a failover divergence, which is what this adapter pair
260+
// must never have.
261+
const finalStdout = () => (retainStdout ? stdout : tailStreamedSandboxOutput(stdout))
262+
const finalStderr = () => (retainStderr ? stderr : tailStreamedSandboxOutput(stderr))
249263
try {
250264
await this.sandbox.process.createSession(sessionId)
251265
sessionCreated = true
@@ -282,14 +296,17 @@ class DaytonaSandboxHandle implements SandboxHandle {
282296
const appendOutput = (
283297
chunk: string,
284298
append: (value: string) => void,
299+
retain: boolean,
285300
callback?: (value: string) => void
286301
) => {
287-
try {
288-
outputBudget.add(chunk)
289-
} catch {
290-
void this.kill().catch(() => {})
291-
resolveOutputLimit()
292-
return
302+
if (retain) {
303+
try {
304+
outputBudget.add(chunk)
305+
} catch {
306+
void this.kill().catch(() => {})
307+
resolveOutputLimit()
308+
return
309+
}
293310
}
294311
append(chunk)
295312
callback?.(chunk)
@@ -302,17 +319,19 @@ class DaytonaSandboxHandle implements SandboxHandle {
302319
appendOutput(
303320
chunk,
304321
(value) => {
305-
stdout += value
322+
stdout = retainStdout ? stdout + value : appendStreamedSandboxOutput(stdout, value)
306323
},
324+
retainStdout,
307325
options.onStdout
308326
)
309327
},
310328
(chunk: string) => {
311329
appendOutput(
312330
chunk,
313331
(value) => {
314-
stderr += value
332+
stderr = retainStderr ? stderr + value : appendStreamedSandboxOutput(stderr, value)
315333
},
334+
retainStderr,
316335
options.onStderr
317336
)
318337
}
@@ -355,8 +374,8 @@ class DaytonaSandboxHandle implements SandboxHandle {
355374
if (outcome === 'output-limit' || outputBudget.error) throw outputBudget.error
356375
if (outcome === 'timeout') {
357376
return {
358-
stdout,
359-
stderr: stderr || `Command timed out after ${options.timeoutMs}ms`,
377+
stdout: finalStdout(),
378+
stderr: finalStderr() || `Command timed out after ${options.timeoutMs}ms`,
360379
exitCode: 124,
361380
timedOut: true,
362381
}
@@ -365,12 +384,17 @@ class DaytonaSandboxHandle implements SandboxHandle {
365384
if (outputBudget.error) throw outputBudget.error
366385
const timedOut = isDaytonaExecutionTimeout(streamError)
367386
if (!timedOut) throw streamError
368-
return { stdout, stderr: stderr || getErrorMessage(streamError), exitCode: 124, timedOut }
387+
return {
388+
stdout: finalStdout(),
389+
stderr: finalStderr() || getErrorMessage(streamError),
390+
exitCode: 124,
391+
timedOut,
392+
}
369393
}
370394

371395
const finished = await this.sandbox.process.getSessionCommand(sessionId, commandId)
372396
const exitCode = finished.exitCode ?? 0
373-
return { stdout, stderr, exitCode }
397+
return { stdout: finalStdout(), stderr: finalStderr(), exitCode }
374398
} catch (error) {
375399
if (isSandboxOutputLimitError(error)) {
376400
void this.kill().catch(() => {})
@@ -382,10 +406,15 @@ class DaytonaSandboxHandle implements SandboxHandle {
382406
: new DOMException('Execution cancelled', 'AbortError')
383407
}
384408
if (isDaytonaExecutionTimeout(error)) {
385-
return { stdout, stderr: stderr || getErrorMessage(error), exitCode: 124, timedOut: true }
409+
return {
410+
stdout: finalStdout(),
411+
stderr: finalStderr() || getErrorMessage(error),
412+
exitCode: 124,
413+
timedOut: true,
414+
}
386415
}
387416
if (operation === 'code') throw error
388-
return { stdout, stderr: stderr || getErrorMessage(error), exitCode: 1 }
417+
return { stdout: finalStdout(), stderr: finalStderr() || getErrorMessage(error), exitCode: 1 }
389418
} finally {
390419
if (sessionCreated) {
391420
try {

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)