The execution boundary for v2. Adds the local docker/podman sandbox adapter and the hardened host subprocess runner every other delivery adapter will use. - adapters/process.py: the one place the platform shells out. argv-only (no shell), start_new_session + killpg on timeout, concurrent stdout/stderr drains with a byte cap, explicit minimal env (never os.environ), and a missing binary reported as spawn_failed rather than an exception. - adapters/docker_sandbox.py: DockerSandboxAdapter over SandboxProviderPort. --user 1000:1000, --cap-drop ALL, no-new-privileges, pids/cpu/memory limits, --read-only with tmpfs scratch, one bind mount (the work tree, never the git dir), labels, no published ports. Path-safe file access; secrets refused in `docker exec -e`. Extras for later phases: start_demo_container (loopback publish only), list_managed_containers, remove_container. - registry: SANDBOX_PROVIDER=docker, cached singleton so the workspace registry survives create_workspace -> start_session. - sandbox_gateway worker: reap_orphans() removes labelled containers no live session points at, audited as sandbox.reap; run_pass() does both passes. - sandbox router: every mutating route now requires the platform owner (PLAN-v2 §7.7); read-only status routes stay open to org members. - sandbox-panel: canManageInfra prop (fails closed) hides the create form, the session panel and the action buttons from non-owners. Tests: 346 API (+37), 67 web (+3); ruff, mypy, tsc and lint clean. Three existing tests now drive the sandbox as the platform owner, since that is who may touch it. Report: docs/plan/reports/agent-3-phase-1.md
8.0 KiB
Agent 3 — Phase 1 report: Docker sandbox adapter
Date: 2026-10-13 · Branch: dev (work done directly on dev; see "Deviations")
What was done
1.1 DockerSandboxAdapter (app/infrastructure/adapters/docker_sandbox.py)
Implements SandboxProviderPort over the docker/podman CLI, with the binary taken from
SANDBOX_DOCKER_BIN so podman is a drop-in (the flags used are the common subset).
create_workspace— createsSANDBOX_WORKSPACES_ROOT/<org>/<project>/workat0700, and encodes the workspace identity in the external id (ws-<org>-<project>) so a restarted process can rebuild the host paths without a database round-trip. One workspace per application, per D5.start_session—docker run -dwith the full hardening set:--user 1000:1000,--cap-drop ALL,--security-opt no-new-privileges,--pids-limit 512,--cpus,--memory,--read-only+--tmpfs /tmp+--tmpfs /home/app, a single bind mount of the work dir at/workspace, labelssmart-org.org/smart-org.project/smart-org.role, default bridge network and no published ports. It reuses an existing container of the same name rather than stacking duplicates after a restart.run_command—docker exec -w <cwd> <ctr> sh -lc <cmd>.cwdandon_outputare adapter-level extras for the engine; the port signature is unchanged. Environment variables are passed with-e, and any key that looks like a secret is refused — argv is visible inps, so credentials must never travel that way.read_file/write_file— through the host bind mount, withsafe_joinpath safety (rejects.., absolute escapes and symlinks that resolve outside the work dir).pause/resume/destroy— stop / start /rm -f -v.snapshot—docker commit, documented as capturing the root filesystem only (the work tree is a bind mount and is not included).get_preview_url—http://127.0.0.1:<port>, and it refuses a platform-protected port.- Extras used by later phases:
start_demo_container(loopback-only publish, same hardening,role=demo),list_managed_containers,remove_container,host_dir_for,binary_available, plus the module-levelimage_for_languagemap (node:20-bookworm, python:3.13-bookworm, php:8.3-cli, golang:1.23, debian:bookworm fallback).
1.1a app/infrastructure/adapters/process.py (new, shared)
The one place the platform shells out. run_process exists to make four failure modes
impossible:
- shell injection —
create_subprocess_execwith an argv list, nevershell=True; - orphaned children —
start_new_session=Trueandkillpgon timeout (SIGTERM then SIGKILL), so adocker build's helpers die with it; - pipe deadlock — stdout and stderr drained concurrently by two tasks, with a byte cap applied while streaming (the callback still sees every chunk, so live logs stay complete);
- secret inheritance — an explicit minimal
env(a handful of safe keys), neveros.environwholesale.
A missing binary returns spawn_failed=True rather than raising; a non-zero exit or a
timeout is an ordinary result the caller inspects.
1.2 Registry
get_sandbox_provider() now maps SANDBOX_PROVIDER=docker to the adapter (cached as a
singleton so the workspace registry stays consistent between create_workspace and the
start_session that follows). mock remains the default; daytona/e2b are unchanged.
1.3 Orphan reaping
SandboxGatewayWorker.reap_orphans() lists every container carrying our labels and removes
any whose id is not in SandboxSessionRepository.list_active_external_ids() (new method).
Each removal is audited as sandbox.reap with reason=orphan_container. A provider without
list_managed_containers is skipped silently.
SandboxGatewayWorker.run_pass() runs idle-session reaping and orphan reaping in one pass,
and workers/__main__.py now calls it.
1.4 Infrastructure hidden from non-owners
app/interfaces/http/routers/sandbox.py: every mutating route now requires the platform
owner via a local _require_platform_owner(context) helper — create/destroy workspace,
start/stop session, run command, read/write file, snapshot, preview URL, set limits. The
read-only status routes (templates, effective limits, workspace list, command log) stay
open to org members, because the project page shows them.
components/sandbox-panel.tsx takes a canManageInfra prop (default false, so a call site
that forgets it fails closed). A non-owner sees the limits card, the workspace list and a
short note that infrastructure is managed by the platform owner; the create form, the session
panel and the per-workspace action buttons are not rendered at all (not merely disabled).
(app)/projects/[id]/page.tsx passes canManageInfra={user?.is_platform_owner ?? false}.
Files changed
| File | Change |
|---|---|
app/infrastructure/adapters/process.py |
new — hardened host subprocess runner |
app/infrastructure/adapters/docker_sandbox.py |
new — the docker/podman sandbox adapter |
app/infrastructure/adapters/registry.py |
SANDBOX_PROVIDER=docker branch |
app/infrastructure/repositories/sandbox.py |
list_active_external_ids() |
app/workers/sandbox_gateway.py |
reap_orphans(), run_pass() |
app/workers/__main__.py |
sandbox-gateway pass calls run_pass() |
app/interfaces/http/routers/sandbox.py |
owner-only mutating routes |
tests/test_docker_sandbox.py |
new — 37 tests |
tests/test_workers.py |
2 sandbox tests now use a platform-owner org |
tests/test_e2e_smoke.py |
smoke path signs up as the platform owner |
apps/web/src/components/sandbox-panel.tsx |
canManageInfra gating |
apps/web/src/app/(app)/projects/[id]/page.tsx |
passes the owner flag |
apps/web/src/test/delivery-sandbox-panel.test.tsx |
new — 3 tests |
.gitignore |
.pytest-tmp/ |
Test output
apps/api: 346 passed (was 309; +37)
ruff check . -> All checks passed!
mypy app -> Success: no issues found in 127 source files
apps/web: 67 passed (was 64; +3)
tsc --noEmit -> clean
next lint -> No ESLint warnings or errors
Deviations from the TODO
- No worktrees. The plan's environment (exFAT
/projects, ext4 worktrees under/home/mamad/work/) describes a Linux box. This machine is macOS with the repo at/Users/mamad/Projects/agy-bot/web; all work is done ondevand committed per phase. - Owner-only rather than "admin+ with a setting". The TODO offered both; owner-only is the stated preference and matches "users never touch infrastructure" (PLAN-v2 §1.9).
- Test updates. Making sandbox mutation owner-only breaks three existing tests that drove
those routes as a plain manager.
test_e2e_smoke.pyand the two sandbox-gateway tests intest_workers.pynow sign up as the platform owner. No assertion was weakened; only the actor's platform role changed. tmp_pathneeds a writableTMPDIRon this machine. The pre-existing$TMPDIR/pytest-of-unknowndirectory is rejected by the local sandbox broker, so pytest cannot create its base temp dir. Tests are run withTMPDIR=<repo>/.pytest-tmp/tmproot(now gitignored). This is an environment quirk, not a code change.
Open questions / CHANGE REQUESTS
- None.
RepoWorkspacePort,DeliveryHooksandDeployTargetTesterare consumed exactly as frozen; the docker adapter adds methods rather than changing the port.
Known limitations
- The workspace registry (external id → image) is process-local. A cold process rebuilds the
host dir from the encoded external id but falls back to the default image; in practice
create_workspaceandstart_sessionhappen in the same request. snapshotdoes not capture the work tree (bind mount). Documented in the docstring.- No
@pytest.mark.dockerintegration test yet; the fake runner covers the argv contract, which is where the security properties live. A live round-trip is added in Phase 3, where the build/demo containers make it meaningful.