# 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` — creates `SANDBOX_WORKSPACES_ROOT///work` at `0700`, and encodes the workspace identity in the external id (`ws--`) so a restarted process can rebuild the host paths without a database round-trip. One workspace per application, per D5. - `start_session` — `docker run -d` with 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`, labels `smart-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 sh -lc `. `cwd` and `on_output` are 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 in `ps`, so credentials must never travel that way. - `read_file` / `write_file` — through the host bind mount, with `safe_join` path 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:`, 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-level `image_for_language` map (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_exec` with an argv list, never `shell=True`; - **orphaned children** — `start_new_session=True` and `killpg` on timeout (SIGTERM then SIGKILL), so a `docker 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), never `os.environ` wholesale. 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 1. **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 on `dev` and committed per phase. 2. **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). 3. **Test updates.** Making sandbox mutation owner-only breaks three existing tests that drove those routes as a plain manager. `test_e2e_smoke.py` and the two sandbox-gateway tests in `test_workers.py` now sign up as the platform owner. No assertion was weakened; only the actor's platform role changed. 4. **`tmp_path` needs a writable `TMPDIR` on this machine.** The pre-existing `$TMPDIR/pytest-of-unknown` directory is rejected by the local sandbox broker, so pytest cannot create its base temp dir. Tests are run with `TMPDIR=/.pytest-tmp/tmproot` (now gitignored). This is an environment quirk, not a code change. ## Open questions / CHANGE REQUESTS - **None.** `RepoWorkspacePort`, `DeliveryHooks` and `DeployTargetTester` are 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_workspace` and `start_session` happen in the same request. - `snapshot` does not capture the work tree (bind mount). Documented in the docstring. - No `@pytest.mark.docker` integration 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.