Plan — HTTP Sandbox (#60)¶
Superseded in part by #251: the host described below as part of
workspace_appis now a standalone project (sandbox-host/, own deps/image, env-based config, no shared modules). The wire contract isdocs/sandbox-host-wire.md; operator docs aredocs/sandbox-host.md. This document is kept for the original design rationale.A fourth
Sandboxbackend,HttpSandbox(the client), plus a self-hosted sandbox host service it talks to over HTTP. The host runs in its own pod/Deployment (later HPA), so sandbox execution is decoupled from the app process. Locked via/grill-me; build per/tdd(red-green-refactor) one phase at a time. Gate at the end with the full suite + 100% coverage (no pipe-mask),ruff,ty, and a live canned check against a real host process.Guiding principle:
HttpSandboxis a faithful HTTP wrapper ofLocalProcessSandbox— production runsLocalProcessSandboxtoday, so its behaviour (per-chunk streaming,exit 124timeout, stdout/stderr separation,/-rooted paths,walkversion-stamps the mirror depends on) is the contract.DockerSandbox's degradations (whole-output-at-end) are not a precedent.
Why build, not adopt (rationale, recorded)¶
/grill-me surveyed the 2026 landscape (verified): no drop-in image matches our
Sandbox Protocol without coupling or KVM. microVM servers (microsandbox/libkrun,
Arrakis/cloud-hypervisor, E2B/Firecracker — self-host experimental) all need KVM;
gVisor/Kata are runtimes with no exec/file API. The only "infra does it all" path is
k8s-as-sandbox (kubernetes-sigs/agent-sandbox, llm-sandbox k8s backend:
pod-per-session + pods/exec + tar-over-exec) — but that costs ~1 s warm /
seconds-to-tens-of-seconds cold (image pull dominates) per sandbox and needs a warm
pool at scale. Our model — one warm host pod holding many sandboxes (processes),
create ≈ ms — deliberately avoids the pod-per-session cost and keeps faithful
parity with the production LocalProcessSandbox. So we hand-roll a small FastAPI host
using two standard utilities (setpriv for the privilege drop, direct cgroup v2 fs
writes for limits) rather than adopt.
Key existing seams to hook (don't rebuild)¶
sandbox/protocol.pySandbox— the 12-method contractHttpSandboxand the host both honour. Don't change it (exceptHttpSandboxdocuments thatexpose_portraisesNotImplementedError).sandbox/local_process.pyLocalProcessSandbox—IsolatedProcessSandboxsubclasses it: inherits all 8 file ops + theexecpump/timeout machinery verbatim; the only surgical change to the production class is extracting the exec argv/env construction into an overridable_exec_argvhook.sandbox/mock.pyMockSandbox— injected into the host in L1 unit tests so the whole wire round-trip runs with no isolation, no root.factories.pyget_sandbox— addcase "http": return HttpSandbox(...).config/schema.pySandboxSettings— extend withkind="http"+ nestedhttp; add a new top-levelSandboxHostSettings.config/loader.py—_dataclass_keys/_buildwiring +config.example.yaml.__main__.py— new entrypointpython -m workspace_app.sandbox_host; reuse the boot-step narration (boot_step→ / ✓ / ✗) and config-dump observability.api/registry.pyInvestigationRegistry— unchanged; it already treats the sandbox as a warm cache of the FileStore snapshot (create →sync.restore, idle →kill+sync.mirror). A dead host pod simply looks like a cold sandbox.
Locked decisions (from /grill-me)¶
Topology¶
HttpSandbox= 4thSandboxclient, peer of Local/Docker/Mock.- Host = backend-agnostic FastAPI shell wrapping one injected
Sandbox; production injectsIsolatedProcessSandbox. Same repo, same image, new entrypointpython -m workspace_app.sandbox_host.
Isolation (IsolatedProcessSandbox(LocalProcessSandbox), isolate=False)¶
- No namespaces/jail. Isolation = Linux uid + cgroups (the reason this backend
exists — without per-sandbox isolation + resource caps it would be no better than
LocalProcessSandbox; "sandboxes must not interfere" is a hard requirement). - Per-handle bare numeric uid/gid from a configured pool (
setpriv --reuid/--regid --clear-groups; nouseradd, kernel setuid to a number needs no passwd entry). Freed onkill. - File isolation:
createdoeschown+chmod 700on the handle workspace + sets a default POSIX ACL (setfacl -R -m u:UID:rwx -d -m u:UID:rwx) so files the host (root) later writes are automatically rwx by the handle uid (covers nested writes; keeps the subclass override surface = create/kill only). Fail-loud if the FS lacks ACL support. - Process isolation: distinct uids ⇒ Linux forbids cross-handle kill/ptrace.
- Resource isolation: per-handle cgroup v2 (
memory.max/cpu.max/pids.max).execwraps the command:sh -c 'echo $$ > <cgroup>/cgroup.procs; exec setpriv … -- <cmd>'(join cgroup, then drop privilege). Nopreexec_fn, nosystemd-run. - Fail-loud at host startup if cgroup v2 / delegation is unavailable (isolation is the whole point — never silently degrade).
- Per-handle
TMPDIR/HOMEinside the workspace (mitigates shared/tmp). - Accepted v1 residuals (no namespaces): shared PID view (but cross-uid kill is blocked) + shared network. Cross-handle file/process/resource interference is closed.
Interactive / TTY programs (vim, top, REPL) — option A¶
execstays one-shot, non-interactive (its caller is the LLM agent; humans edit via the IDE, not a terminal). No PTY.stdin=/dev/null(EOF) +TERM=dumbmake almost every TUI exit promptly;cgroup cpu.max+ idlelog_timeout+ process-group SIGKILL + uid isolation are the backstop for any spinner. A real web terminal (PTY + WebSocket + xterm.js) is a separate future feature, out of #60.
Routing (HPA-ready, stateless)¶
createhits the host ClusterIP Service; the chosen pod reports its own direct URL (downward-APIPOD_IP) + its local remote-id.HttpSandboxencodes(pod_url, remote_id)into the opaqueSandboxHandle.id(it owns the id format; the app treats it opaque). Every other method decodes → connects direct to that pod (bypassing the LB). → app side fully stateless, HPA-safe, no shared store, no sticky-routing dependency.- Host pod death (scale-down/crash) → direct call fails → mapped to
SandboxNotFound→InvestigationRegistryrecreates from the snapshot (same as today's idle-kill cold path; loses only in-sandbox ephemeral state). Mitigate with PreStop drain + conservative scale-down.
Wire / API¶
- One endpoint per protocol method (REST-ish, boring). Files = raw
application/octet-streambody (no base64-in-JSON); metadata (walk) = JSON. exec= NDJSON streaming: one line per chunk{"s":"out|err","b":"<base64>"}, final line{"exit":N}. Client forwards each chunk toon_output, buckets out/err separately, rebuildsExecResult. (PreservesLocalProcessSandboxper-chunk streaming — the production behaviour.)- Client read-timeout very large / disabled; the host's
exec_timeout+log_timeoutare the real bounds. expose_port→NotImplementedError(verified zero production callers; no Jupyter / in-sandbox network consumer);exposed_portsignored.- No authentication — host is reachable only inside the k8s namespace (NetworkPolicy / ClusterIP). Residual (any in-namespace service can drive the host) accepted.
- Error model: host returns a structured error (HTTP status +
{type}); client mapstype→SandboxNotFound/FileNotFoundError/NotImplementedError. Connection failure / dead pod →SandboxNotFound. spec.imageignored (no containers);spec.envforwarded toexec. uid pool + handle map guarded by an async lock; per-handle ops independent.
Operations¶
- Graceful drain: SIGTERM →
createreturns 503 (draining) + keep existing sandboxes until idle or a drain deadline (terminationGracePeriodSeconds), then exit. Deployment PreStop hook. - Orphan idle-reaper: the app's
InvestigationRegistry.kill_idlereaps normally; the only leak is an app-pod crash leaving handles unkilled. Host background sweep kills handles idle (incl. no in-flightexecoutput) longer thanidle_ttl. This is a per-handle bound — distinct from and not covered by the existing per-commandexec_timeout/log_timeout. Generous default (≈30 min) ≫exec_timeout; configurable; logs what it reaps (no silent cap). - Health:
/healthz(liveness) +/readyz(cgroup v2 present + delegation OK + can allocate a uid) so k8s routescreateonly to ready pods; startup fail-loud check feeds/readyz.
Config¶
- Client (
SandboxSettings):kind: "http"+http: {base_url, read_timeout=0}. - Host (new top-level
sandbox_host:):bind,uid_min/uid_max,memory_max("512M"),cpu_cores(1.0),pids_max,cgroup_root(None=detect, injectable for tests),root,exec_timeout,log_timeout,tools_dir,idle_ttl. Friendly units translated to cgroup syntax internally. - Only
config.example.yamlis edited; the liveconfig.yamlis off-limits — hand the operator a snippet.
Testing (100% gate without root)¶
- L1 — client + host wire (unit):
HttpSandboxagainst an in-process ASGI host (httpx.ASGITransport) withMockSandboxinjected. Covers serialization, NDJSON streaming parse, raw-bytes, handle encode/decode, error→exception mapping. No root. - L2 —
IsolatedProcessSandbox(unit): every privileged op is seamed to run non-root by parameterizing its target —cgroup_rootinjected to atmp_path(write real files to a fake tree),chowntoos.getuid()(self),setfaclon an owned tmp dir, thesetpriv+cgroup wrapper a pure argv builder asserted as a string. All lines execute as the dev user ⇒ 100%. - L3 — real isolation behaviour (integration): foreign-uid
setprivactually drops privilege,memory.maxactually OOM-kills, cross-uid file/process denial.@pytest.mark.integration+skipif(not root / not cgroup v2). Validates behaviour; never relied on for coverage (its lines are covered by L2). Mirrorstest_local_process.py(whole-moduleintegration).
Phases (flat integers)¶
P1 — Wire protocol + HttpSandbox client + host shell¶
sandbox/http_client.pyHttpSandbox: 12 methods over HTTP; handle id =encode(pod_url, remote_id)/ decode in every method; NDJSONexecstreaming →on_output+ExecResult; raw-bytes upload/download; error→exception mapping; connection-failure →SandboxNotFound;expose_port→NotImplementedError.sandbox/host/app.pyFastAPI host wrapping an injectedSandbox(P1 defaultLocalProcessSandbox(isolate=False)so it works end-to-end with no isolation yet);createreturns{pod_url (from POD_IP), remote_id}.- L1 tests (ASGI +
MockSandbox): full round-trip, streaming, errors, encode/decode. - DoD: a usable (un-isolated) HTTP sandbox;
ruff/tyclean; L1 100%.
P2 — IsolatedProcessSandbox (the real isolation)¶
sandbox/isolated_process.pyIsolatedProcessSandbox(LocalProcessSandbox): uid/gid pool allocator (pure),create= chown +chmod 700+ default ACL + per-handle cgroup v2 create,kill= free uid + remove cgroup,_exec_argvhook =setpriv+ cgroup-join wrapper. Surgical_exec_argvextraction inLocalProcessSandbox.- cgroup manager (injectable
cgroup_root), ACL setter, setpriv builder — all seamed. - fail-loud cgroup v2 / delegation check.
- L2 unit (non-root, 100%) + L3 integration (root-gated, behaviour).
- Host default backend flips to
IsolatedProcessSandbox.
P3 — Config + entrypoint wiring¶
SandboxSettings.kind="http"+httpsub-config;get_sandboxcase "http".SandboxHostSettings+ loader keys;python -m workspace_app.sandbox_hostentrypoint (build the injectedIsolatedProcessSandbox+ host app, servebind), boot-step narration + config dump.config.example.yamladditions + operator snippet (live config untouched).
P4 — Operations¶
- Graceful drain (SIGTERM → 503 on
create+ drain deadline), orphan idle-reaper (idle_ttl, logged),/healthz+/readyz(cgroup/uid readiness fed by the startup fail-loud check).
P5 — Deployment example + docs¶
- Example k8s manifests (Deployment + HPA + ClusterIP Service + NetworkPolicy + PreStop
terminationGracePeriodSeconds+ downward-APIPOD_IP).- Fold this plan's locked decisions into a short operator manual; cross-link from
sandbox/protocol.py's backend list.
Final gate¶
Full suite + coverage combine + --fail-under=100 (no pipe-mask), ruff check +
ruff format --check, ty check, and a live canned check: start a real
sandbox_host process locally, point a HttpSandbox at it, and exercise
create → upload → exec(stream) → download → walk → kill, asserting isolation
(two handles can't read each other; a memory.max breach is killed) under root.