Skip to content

feat(sandbox): Environment seam + Docker per-process backend (Wave D, v1.31.0)#120

Merged
sweetcornna merged 1 commit into
mainfrom
feat/waveD-sandbox-env
Jul 20, 2026
Merged

feat(sandbox): Environment seam + Docker per-process backend (Wave D, v1.31.0)#120
sweetcornna merged 1 commit into
mainfrom
feat/waveD-sandbox-env

Conversation

@sweetcornna

Copy link
Copy Markdown
Owner

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 single Environment seam, with a Docker backend selectable by one env var.

Design

  • SpawnedProcess handle 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. .proc exposes the asyncio subprocess so existing readline/communicate/stdin code is unchanged.
  • LocalEnvironment (default) reproduces the historical create_subprocess_* calls byte-for-byte (workspace cwd, env whitelist, POSIX rlimits + setsid preexec). _build_child_env / _preexec_apply_rlimits / kill_process_group / reap_orphan_group moved verbatim from shell.py to environment.py with 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): one docker run --rm container per spawned process — the container name (corlinman-sbx-<uuid>) is the kill token, because a host killpg cannot reach in-container PIDs and long-lived-container + exec PID tracking is fragile. Hardening flags mirror the local rlimits (no-new-privileges, --pids-limit=64, --memory=2g, --ulimit nofile/cpu/fsize); only LANG/LC_ALL/TZ are forwarded (never PATH/HOME/secrets); REPL runs the in-image interpreter with -i and never -t (a tty would corrupt the marker framing). Errors subclass OSError (SandboxSpawnError / DaemonUnavailableError) so the existing except-OSError → spawn_failed envelope paths work with zero call-site changes; a missing docker binary fails loudly at spawn instead of silently falling back to the host.
  • Knobs: CORLINMAN_SANDBOX_IMAGE (default python:3.12-slim-bookworm), CORLINMAN_SANDBOX_USER (--user remap 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:

  1. Server suicide: a docker run client doesn't fork, so without setsid it 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 get preexec_fn=os.setsid (deliberately not the rlimit preexec — that would bind the host docker CLI; --ulimit owns in-container limits), and ShellTaskRegistry._reap_task dispatches 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.
  2. Foreground-timeout hang: DockerSpawnedProcess.kill() sweeps the (now setsid'd) client group after docker kill, so the dispatcher's await 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 kill is a sync subprocess.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.
  • The workspace bind-mount is RW by design and container networking stays on (parity with the local shell) — the sandbox narrows exposure of the host outside the workspace and of gateway process-env secrets; it is not a security-boundary replacement (_DENY still screens pre-spawn on every backend).

Verification

  • 3 independent adversarial verifiers (byte-parity, reap lifecycle, docker boundaries): all pass.
  • Full local gate green: ruff, mypy (595 files), pytest 6700 passed / 4 skipped (host-env-scrubbed; ANTHROPIC_BASE_URL leakage 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).
  • ~33 new tests in 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_task dispatch, and docker-gated real round-trips (skip cleanly when no daemon).

Note for merger: like the sibling wave PRs (#114#119), this bumps pyproject.toml/uv.lock/CHANGELOG.md (→ v1.31.0), so a trivial version/CHANGELOG conflict with whichever sibling merges first is expected; resolve by keeping both entries and the higher version.

@github-actions github-actions Bot added codex:needs-review A Codex review is needed for the current PR head. codex:review-requested A Codex review was requested or should be running automatically. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 8, 2026
… 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
sweetcornna force-pushed the feat/waveD-sandbox-env branch from 5743b08 to ef82d8d Compare July 20, 2026 01:51
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added codex:needs-rerun A new push or stale result means @codex review should be requested again. codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. and removed codex:review-requested A Codex review was requested or should be running automatically. codex:needs-review A Codex review is needed for the current PR head. codex:needs-rerun A new push or stale result means @codex review should be requested again. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 20, 2026
@sweetcornna
sweetcornna merged commit a466877 into main Jul 20, 2026
10 checks passed
@github-actions github-actions Bot added the status: ✅ merge-ready Evidence and review are clear; normal merge gates may proceed. label Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: ✅ merge-ready Evidence and review are clear; normal merge gates may proceed. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant