feat(sandbox): Environment seam + Docker per-process backend (Wave D, v1.31.0)#120
Merged
Conversation
… v1.31.0) All three code-execution tools (foreground run_shell, background shell-task registry, persistent REPL) now spawn through a single Environment seam: - SpawnedProcess handle ABC (.proc/kill()/reap()) — termination travels with the child, not the backend, because a REPL session outlives its spawn call. - LocalEnvironment (default): byte-identical to the historical create_subprocess_* calls; helpers moved verbatim shell.py→environment.py with re-exports (shell imports environment; leaving them would be circular). - DockerEnvironment (CORLINMAN_SANDBOX_BACKEND=docker): one docker run --rm container per spawned process; container name is the kill token (host killpg cannot reach in-container PIDs). Hardening flags mirror the local rlimits; only LANG/LC_ALL/TZ forwarded; errors subclass OSError so the existing spawn_failed envelope paths work with zero site changes. Knobs: CORLINMAN_SANDBOX_IMAGE (default python:3.12-slim-bookworm), CORLINMAN_SANDBOX_USER (--user remap for Linux bind-mount ownership). - get_environment selector: live-read per call, loud RuntimeError on unknown. Pre-empted second-order edges (found in director review, adversarially verified): - docker clients get preexec_fn=os.setsid so shell_tasks' terminal killpg reap can never reach the SERVER's process group (would have SIGKILLed the whole server under the docker backend). - DockerSpawnedProcess.kill() sweeps the client group after docker kill, so the fg-timeout await proc.wait() cannot hang on a hung daemon. - ShellTaskRegistry._reap_task: handle-aware dispatch at all 7 terminal sites; local path and its spy-based parity tests stay byte-identical. Trade-offs recorded in CHANGELOG: sync docker kill (10s-bounded, atexit needs sync); container may leak if the daemon hangs mid-kill; RW workspace mount + container networking on by design (not a security-boundary swap).
sweetcornna
force-pushed
the
feat/waveD-sandbox-env
branch
from
July 20, 2026 01:51
5743b08 to
ef82d8d
Compare
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave D — Dim 4 sandbox backend abstraction (phase 1)
Gap-close program wave D (follows #114–#119). All three code-execution tools — foreground
run_shell, the background shell-task registry, and the persistent REPL — now spawn through a singleEnvironmentseam, with a Docker backend selectable by one env var.Design
SpawnedProcesshandle ABC (.proc/kill()/reap()): termination logic travels with the spawned child, not the environment — a persistent REPL session outlives the call that spawned it, so its kill must not depend on re-resolving a backend later..procexposes the asyncio subprocess so existing readline/communicate/stdin code is unchanged.LocalEnvironment(default) reproduces the historicalcreate_subprocess_*calls byte-for-byte (workspace cwd, env whitelist, POSIX rlimits + setsid preexec)._build_child_env/_preexec_apply_rlimits/kill_process_group/reap_orphan_groupmoved verbatim fromshell.pytoenvironment.pywith re-exports (shell imports environment; leaving them in place would be circular). Every pre-existing reap parity test passes unmodified.DockerEnvironment(CORLINMAN_SANDBOX_BACKEND=docker): onedocker run --rmcontainer per spawned process — the container name (corlinman-sbx-<uuid>) is the kill token, because a hostkillpgcannot reach in-container PIDs and long-lived-container +execPID tracking is fragile. Hardening flags mirror the local rlimits (no-new-privileges,--pids-limit=64,--memory=2g,--ulimit nofile/cpu/fsize); onlyLANG/LC_ALL/TZare forwarded (neverPATH/HOME/secrets); REPL runs the in-image interpreter with-iand never-t(a tty would corrupt the marker framing). Errors subclassOSError(SandboxSpawnError/DaemonUnavailableError) so the existing except-OSError →spawn_failedenvelope paths work with zero call-site changes; a missing docker binary fails loudly at spawn instead of silently falling back to the host.CORLINMAN_SANDBOX_IMAGE(defaultpython:3.12-slim-bookworm),CORLINMAN_SANDBOX_USER(--userremap for Linux hosts where root-owned files in the RW bind-mounted workspace would trip host tooling). All read live per spawn.Second-order edges pre-empted (director review → adversarial verify)
Per the parity-program lesson (every shell/permission fix opens a second-order edge), two defects were found in review and fixed before merge:
docker runclient doesn't fork, so withoutsetsidit shares the server's process group — shell_tasks' terminal reap (killpg(getpgid(pid))) would have SIGKILLed the entire server under the docker backend. Docker clients now getpreexec_fn=os.setsid(deliberately not the rlimit preexec — that would bind the host docker CLI;--ulimitowns in-container limits), andShellTaskRegistry._reap_taskdispatches handle-aware at all 7 terminal sites (watchdog, pump-setup failure, log-cap, natural exit, explicit kill, shutdown, atexit) while keeping the local path byte-identical and spy-compatible.DockerSpawnedProcess.kill()sweeps the (now setsid'd) client group afterdocker kill, so the dispatcher'sawait proc.wait()unblocks even when the daemon is hung. (The container may leak in that edge — a hung daemon can't be asked to remove it — documented trade-off.)Recorded trade-offs
docker killis a syncsubprocess.run(10s-bounded) because the atexit path needs sync; a hung daemon can stall the loop up to 10s per terminal reap on the opt-in docker backend only. Async offload at non-atexit sites is a phase-2 candidate._DENYstill screens pre-spawn on every backend).Verification
ANTHROPIC_BASE_URLleakage from the dev host breaks provider respx tests on any branch until feat(parity): Wave E — settings persistence, exit_plan_mode, --fork-session, fallback signature strip, hermetic tests (v1.30.0) #119's hermetic conftest merges), ui typecheck/lint/build, gen-proto diff-clean, lint-imports (2 kept, 0 broken).test_environment.py: selector, pure argv-builder pins (hardening flags, env-forward allowlist with secret-leak assertions,-i-not--t,--user), daemon-absent gate, fake-exec wiring (incl. setsid-not-rlimits on docker clients), local round-trips, bogus-backend envelopes, repl-disabled-never-spawns,_reap_taskdispatch, and docker-gated real round-trips (skip cleanly when no daemon).