Skip to content

Make a local sandbox's commands read no input, end with the command, and keep a bounded tail of valid text - #135

Merged
earakely-scale merged 2 commits into
mainfrom
edgararakelyan/local-exec-io
Oct 9, 2026
Merged

earakely-scale merged 2 commits into
mainfrom
edgararakelyan/local-exec-io

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Every step that runs a command on a local sandbox goes through LocalSandbox.exec_with_output: verify_sandbox checks, setup scripts, builds and copies. That call misbehaved in four ways.

Before / after, measured on main and on this branch:

Command Before After
cat >/dev/null (reads input) while this process's stdin is an open pipe, as in a worker hangs until its timeout: it read agent-env's own stdin finishes at once: commands read /dev/null
sleep 6 & true (leaves a job in the background) times out: the job holds the output pipe open, and the call waited for it ends with the command (about 1 s), exit code kept
printf '\xff' fails with UnicodeDecodeError though it exited 0 passes; bytes that aren't UTF-8 become �
300 MB of output kept whole: peak ~1.08 GB of memory the last 64 MiB kept: peak ~320 MB, bounded however large the output; a warning names the sandbox and how much was dropped

How

All in local_sandbox.py. Remote providers and exec(), which collect_artifacts streams from, are unchanged.

  • stdin. exec() passes stdin=DEVNULL. Nothing in core writes to a sandbox command's stdin.
  • Background jobs. Process.wait() also waits for the command's pipes to close, so the exit is checked for directly while wait() runs (_exit_code). Output is still read for 1 s after the command ends, and then our ends of its pipes are closed. That is what a remote exec session ending does, and why host installs start their agent with nohup … > log 2>&1 &. A background job still writing to the command's output is cut off with it.
  • Output size. Each stream keeps its last 64 MiB (_Tail). That is above the largest output core reads whole through this call, run_code's 50 MiB result, and a test pins it there. It holds chunks because cutting the front off one growing bytearray keeps the memory it grew to: 590 MB to hold a 16 MB tail in a micro-benchmark. The warning leaves the command out, since commands can carry secrets.
  • Decoding. errors="replace".
  • Cancellation. _stop now waits for the exit the same way and closes the pipes. A cancelled command whose job escaped the kill no longer holds file descriptors, or a 5 s wait.

The pipes are closed through process._transport, since asyncio has no public way to close a subprocess's pipes.

Verified

A chaos script ran on macOS and on Linux (python:3.12-slim), with the process's stdin a held-open pipe. Every check passed on both:

Check Result
A hang, a FIFO read, and grandchildren under wait, each under a timeout times out; no process left
50 timeouts in a row no process left; open fds and threads back to baseline (9 / 1)
sleep … & true, and sleep … & exit 3 end in ~1.1 s, exit codes 0 and 3
yes & writing to the command's output cut off; nothing left running
cat >/dev/null finishes
yes under a 3 s timeout memory +0 to 39 MB (was unbounded)
printf 'ok \xff\xfe end' passes, ok �� end
55 MB output (a run_code result near its limit) kept whole
70 MB output last 64 MiB kept, ending intact
3 MB on stderr with a small stdout no deadlock
64 concurrent ~200–260 KB base64 outputs every byte intact (hash-checked)
read_vm_file of 50 MiB identical
verify_sandbox step with the background-job, non-UTF-8, stdin and hang checks the first three pass; the hang times out at 3 s; nothing left

Unit tests pass, including new ones for stdin, a background job holding the output, a job cut off with it, decoding, and the tail. The local Docker integration tests (test_run_docker_container_local.py, test_agent_on_local_sandbox.py) pass on macOS.

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge; both earlier findings are addressed.

Summary

Local sandbox commands no longer read the worker’s stdin, and output calls can finish when the command exits even if a background job keeps a pipe open. Each returned stream keeps its last 64 MiB, with invalid UTF-8 bytes replaced when decoded.

  • Local command calls finish when the command exits, even if a job keeps a pipe open.
  • Local command output keeps a readable, bounded tail for each stream.

Reviews (2) · Last reviewed commit: "Keep 64 MiB of a local command's output,..." · Reviewed by Greptile

…and keep a bounded tail of valid text

A local command inherited this process's stdin, so one that read input hung until its timeout. A
job it left in the background held its output open, so a command that had exited timed out.
Its output was kept whole, and a byte that wasn't UTF-8 failed a command that had passed.

Commands now read from /dev/null. The call ends with the command: Process.wait() also waits
for the pipes to close, so the exit is checked for directly, and the pipes are closed a second
later, as a remote exec session ending would. Each stream keeps its last 16 MiB, in chunks so
memory stays bounded, and decodes with replacement characters. A cancelled command's pipes are
closed too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 9, 2026 05:30
Comment thread src/agent_env/providers/sandbox_providers/local_sandbox.py
Comment thread src/agent_env/providers/sandbox_providers/local_sandbox.py Outdated
…t, and leave the command out of the warning

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit b6fbef5 into main Oct 9, 2026
13 of 14 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/local-exec-io branch October 9, 2026 05:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant