Skip to content

Containerized runner: rootless podman panes — tmux keeps the session, the pane command becomes podman run -it --rm, and a per-repo repos.runner seam keeps the unsandboxed host pane as labeled break-glass

Until now every session pane ran the provider CLI directly on the host under a prlimit nofile cap — full host access for the agent: other repos' worktrees and bares, the state dir, master.key, and every loopback service lab itself talks to. The 2026-07-22 container-isolation design closes that, and issue #205 ships its tracer bullet: the first end-to-end containerized run, consuming the three seams the preceding slices laid down — the agent-API unix socket (#201), the per-run instance HOME (#202), and the agent-tools injection images (#203 / ADR-0051). The move is deliberately narrow: nothing about how lab runs sessions changes except what the pane command is.

The pins, decided:

  • The tracer split: tmux stays host-side and untouched — containerizing a run means the pane command BECOMES podman run -it --rm …, nothing more. tmux still owns liveness, attach, send-keys, and capture; the podman client process in the pane owns the container's lifetime. The seam is the per-repo repos.runner pick (NOT NULL, two values, validated app-side like tracker_binding): host is today's prlimit-wrapped pane, kept as break-glass and labeled "unsandboxed — full host access" in the UI; container is rootless podman + crun. host stays the default — for existing repos by migration default and for fresh ones by stamp — until the preflight matrix is proven on the dev host; flipping the default is explicitly out of this slice.

  • The mount inventory is five binds plus the tools image, each with a stated reason — and every path except one is host-identical, so path-valued state needs no rewriting. The worktree, rw at its host path and the container workdir (-w, mirroring tmux's new-session -c): the agent edits and the server diffs the same tree. The bare repo, rw at its host path: the worktree's .git file points into it by absolute host path — the worktree alone would be a broken repo inside. The agent socket directory <state>/agent, never the socket file: a bind of the socket inode pins whatever inode existed at container start, and a server restart unlinks and recreates the socket — a dead inode for every container that outlives the restart (KillMode=process guarantees they do). This is what forced moving the socket from <state>/agent.sock into its own directory, at <state>/agent/agent.sock, with a back-compat symlink kept at the old path. The instance HOME (#202) at /home/agent — the ONE non-host-identical mount, giving the agent a stable, host-layout-independent HOME while the server keeps reading the same tree at its host path; env values the provider derived from the host home (HOME=, CLAUDE_CONFIG_DIR=, CODEX_HOME=) are rewritten to the container view by exact-or-path-prefix match, never substring. The per-run runtime dir (<state>/instances/<runID>/runtime) at its host path — materialized git credential files, known_hosts, the dialog spool, the per-run --settings file — made per-run by this slice precisely so the mount exposes exactly one run's secret surface, never another run's (the old shared <state>/runtime would have leaked every live run's git credentials into every container). And ADR-0051's agent-tools image, read-only at /opt/lab via --mount type=image — the destination is that ADR's hard PT_INTERP contract, not a knob.

  • --userns=keep-id, --network=pasta:--map-guest-addr,none, and --cgroups=split. keep-id maps the agent to the service uid inside the container, so files it writes into the shared mounts (worktree, home, runtime) are owned by the same uid host-side and the server tails and diffs them with no chown dance. pasta gives full egress with no dedicated route back to host services: the agent reaches lab only through the mounted unix socket, never a host port — a TCP --agent-url is deliberately unreachable from inside, so container runs always get the unix LAB_URL regardless of that flag. Podman's own pasta defaults get only half of that (amended by #216 — see Status): --no-map-gw closes the loopback/gateway path, but podman also appends --map-guest-addr 169.254.1.2, which maps host.containers.internal onto the host's global address — so every host service bound to a wildcard address, lab's own --addr :8080 included, stayed reachable from inside every run container. The argv therefore pins --network=pasta:--map-guest-addr,none in every shape the one renderer serves — run pane, login pane, non-interactive CLI poke — where none is pasta's documented literal for "no address is mapped"; supplying the option composes with podman's remaining pasta defaults rather than replacing them, since containers/common only skips the --map-guest-addr it would otherwise append and still adds --no-map-gw, -t none/-u none/-T none/-U none, and --dns-forward 169.254.1.1 — egress and DNS forwarding are untouched. Dropping the mapping does not by itself unname the host, though — with no address mapped podman re-points host.containers.internal / host.docker.internal at the first other global-unicast host address it can find, a bridge or a second NIC or a VPN — so the same three shapes also pin --add-host host.containers.internal:127.0.0.1 and --add-host host.docker.internal:127.0.0.1, which claim both names before podman's automatic entry can take them and land them on the container's own loopback, where every connection refuses instantly. Neither pin suffices alone: the option kills the dedicated mapping (whose address the guest shadows anyway), the --add-host pair kills the fallback. What survives is stated, not papered over — egress is untouched, so a wildcard-bound host service on such a non-shadowed address stays reachable by raw IP (measured on the dev host), and closing that would need egress filtering, which #216 puts out of scope. Binding co-located host services to 127.0.0.1 is therefore the control on a multi-address host, not defense in depth. --map-guest-addr none is 2025_04_15-or-newer passt territory, so the preflight probes the exact option and value rather than letting an old passt surface as a container that will not start (below). --cgroups=split places the container's payload cgroup as a child of lab's OWN cgroup (<lab's cgroup>/libpod-payload-<id>) rather than podman's rootless default of user.slice; without it the container lands in a subtree the preflight never checks, and its --memory/--pids-limit caps could be silently absent behind a green preflight. Split reuses lab's (delegated) cgroup for both conmon and payload, so it requires Delegate=yes on the unit — which the preflight enforces.

  • Env discipline: nothing inherited. The container env is only what the argv states. Non-secret container-side values (PATH, HOME, LAB_URL, the GIT_* file paths) travel as --env K=V argv entries. Secrets — LAB_TOKEN — travel by the #204 mechanism extended across the container boundary: tmux seeds them into the pane environment via new-session -e (values out of ps), and the pane's podman client forwards them into the container by NAME-ONLY --env K flags, so no secret value ever appears in any argv. PATH pins /opt/lab/bin first: the lab-pinned CLI and static labctl (ADR-0051) beat any same-named binary the dev image carries, while the image's own userland stays reachable behind them.

  • Limits come from settings rows and are per-repo overridable; the prlimit wrapper is retired for container panes only. --memory / --pids-limit / --ulimit nofile read the global container_memory / container_pids / container_nofile settings (seeded 8g / 4096 / 16384 — always seeded, because a container run needs a concrete limit and there is no lower layer to inherit from), with nullable per-repo overrides on the repo row (NULL = inherit the global, non-NULL pins the repo regardless of later global changes). The host prlimit wrapper is retired for container panes — the pane process is now the podman client, and capping that would aim at the wrong target; host panes keep it unchanged.

  • Preflight runs at startup and its refusal lands before the claim. The checks: podman present, runnable, and >= 4 (the flag set is podman-4 territory); pasta present and new enough to take --map-guest-addr none — one pasta check id covering missing or too old, exactly as the podman check covers missing/unrunnable/too old: whenever pasta is on PATH (a missing one is already reported and must not be blamed twice) preflight runs the exact option and value the argv passes, pasta --map-guest-addr none --version, judged on exit status alone, and reads a non-zero exit as a passt older than 2025_04_15, hinting the upgrade — the option/value pair rather than a parsed version or a pasta --help grep because the floor has two stages (2024_08_21 added the option; releases through 2025_03_20 accept it and reject the value none, which 2025_04_15 fixed), so a host in that window advertises --map-guest-addr in its help text, passes either weaker test, and then fails every container at start; and because podman's backwards-compat retry covers only the --map-guest-addr default it appends and never the option lab supplies — so on an old passt the argv would otherwise fail at container start, per spawn, instead of at startup; subuid and subgid ranges for the service user (keep-id needs the user namespace); cgroup v2 with memory+pids delegated to lab's own cgroup — checked two ways, because neither alone is sufficient: the controllers must be available in lab's cgroup (cgroup.controllers, i.e. the parent delegated them inward) and the cgroup must be writable by the lab user (the durable Delegate=yes signal — cgroup.controllers reads true even on a non-delegated unit, and cgroup.subtree_control reads empty on a delegated-but-idle one, so only write access distinguishes them). Without real delegation the --cgroups=split payload cgroup cannot be created with limits and the caps would be silently absent — worse than failing, since a bounded blast radius is the whole point; a dev image configured (no default image on purpose — the operator owns the userland, ADR-0051); and every configured agent-tools ref resolvable (local image exists, else one pull — the ref is digest-pinned, so a successful pull is the permanent fix). Preflight never aborts early: all failures are collected into one message, each with the operator action that fixes it, so one refusal names everything wrong at once instead of one restart at a time. Container spawns on an unready host are refused with that message — and for AFK work the refusal happens before the claim, so no issue is ever parked by a host that cannot run its container.

  • Pane liveness is still the only liveness; stop and orphan removal need no stored state. tmux kill-session SIGHUPs the attached podman client, which sig-proxies into the container, and --rm reaps it — a clean stop leaves nothing behind. For a provider CLI that ignores SIGHUP, the backstop is podman rm --force --ignore: container names are deterministic (labrun- + the sanitized session name + a sha256 collision-guard suffix), recomputable from the session name alone, so Stop and the startup orphan sweep can remove leaked containers with no container-id column and nothing to lose across a restart. KillMode=process survival is unchanged: a lab restart kills neither the tmux server nor the container under its pane, and re-adoption reads pane liveness exactly as before.

  • The pre-push hook needed NO change. The guarded hook already shells out to labctl by bare PATH lookup, and the container PATH carries /opt/lab/bin, where ADR-0051's static labctl lives. One hook text serves both runners; no hook-marker migration.

Status

Accepted. Settled via issue #205 (2026-07-22, the grilled container-isolation design). The consumer ADR-0051 named: reads the /opt/lab injection contract and pins the @sha256 digest reference. Builds on #201 (the agent unix socket — moved by this ADR into <state>/agent/ so the socket's directory can be mounted, with a back-compat symlink at the old path), #202 (the per-run instance HOME — joined here by a per-run runtime dir so the container mounts exactly one run's secret surface), and #204 (tmux -e secret env — extended across the container boundary by name-only --env forwarding). Does not revisit ADR-0002's KillMode=process re-adoption: tmux remains the process container and the sole liveness authority; the podman client is just what the pane runs. Containerized login (#206) and per-repo dev images (repos.image_ref, #207) are deliberate follow-ups — the tracer runs every container repo on the one global --container-image.

Amended by issue #216 (2026-08-03): the network pin --network=pasta becomes --network=pasta:--map-guest-addr,none, in every shape the one renderer serves. This ADR's "no route back to the host" claim was only ever true of loopback-bound host services — podman appends --map-guest-addr 169.254.1.2 to its pasta defaults, mapping host.containers.internal onto the host's global address, and its --no-map-gw default closes only the loopback/gateway path, so any host service on a wildcard address, lab's own operator HTTP server (--addr :8080) included, was reachable from inside every run container: reproduced live from a run container, curl http://host.containers.internal:8080/ answered 200 while the guest's own 127.0.0.1:8080 did not. none is pasta's literal for "no address is mapped", and the option composes with podman's remaining pasta defaults (--no-map-gw, -t/-u/-T/-U none, --dns-forward 169.254.1.1) instead of replacing them, so egress and DNS forwarding are unchanged. The option alone does not unname the host — with no mapped address podman falls back to the first global-unicast host address that is not the container's own — so the argv pins --add-host host.containers.internal:127.0.0.1 and --add-host host.docker.internal:127.0.0.1 alongside it, user entries podman writes first and whose names suppress its automatic one; both names then resolve to the container's own loopback and refuse. The residual is admitted rather than hidden: egress is untouched, so a wildcard-bound host service on a non-shadowed host address (a bridge, a second NIC, a VPN) stays reachable by raw IP — measured — which makes binding co-located host services to 127.0.0.1 the control on a multi-address host rather than a nicety; egress filtering is out of #216's scope. Preflight's pasta check widens in place — same check id, now "missing or too old", probing the exact option and value (pasta --map-guest-addr none --version, judged on exit status) when pasta is on PATH — because the floor is passt 2025_04_15: 2024_08_21 added --map-guest-addr, but conf_nat() matched the none literal without returning, fell through into inet_pton and died Invalid address to remap to host: none until 2025_04_15 added the missing return, so a host in that window advertises the option and would pass a version gate or a help grep; podman's backwards-compat retry never covers a user-supplied option either. Everything else this ADR decided stands.

Considered options

  • Bind-mounting the socket file instead of its directory. Rejected: a socket-file bind pins the inode that existed at container start; a server restart recreates agent.sock as a new inode, and every container that outlives the restart — which KillMode=process guarantees they do — would be talking to a corpse. Mounting <state>/agent makes the recreated socket visible through the directory. The cost is the path move plus one back-compat symlink; the alternative cost is every containerized session going deaf on every deploy.

  • Detached containers with podman as a second liveness source. Rejected: pane liveness is the one liveness the whole system already trusts — the reaper, re-adoption, the UI badges. A detached container (podman ps as truth) forks liveness into two sources that can disagree. Running the podman client as the pane command keeps the container's lifetime exactly the pane's, and the existing kill/adopt machinery needs no second opinion.

  • Keeping the prlimit wrapper around the container pane. Rejected: the pane process is now the podman client, not the agent — a host-side nofile cap on it constrains the wrong process. The cap moves inside as --ulimit nofile, joined by the memory and pids caps prlimit never provided.

  • Storing the container name or id on the run row. Rejected: a deterministic name derived from the session name means Stop and the startup orphan sweep can address any leaked container with no stored state to lose, migrate, or let drift. Sanitization into podman's name alphabet is lossy, so a sha256-prefix suffix guards collisions instead of a stored id.

Consequences

  • The agent socket path moved. The socket now lives at <state>/agent/agent.sock; a symlink at the old <state>/agent.sock keeps existing LAB_URL=unix://…/agent.sock values and any operator tooling working. docs/ops.md carries the upgrade note.
  • Container mode is operator-provisioned, and preflight is the honesty layer. podman >= 4, crun, passt, subuid/subgid ranges, cgroup-v2 delegation, a rootless runtime dir, and the two image knobs (ops.md § Container runner) — a missing prerequisite becomes an actionable spawn refusal, never a half-running session with silently absent limits.
  • The dev image is a real contract surface. It brings the userland (shell, coreutils, git; an ssh client for ssh-remoted repos); lab injects only the agent layer read-only (ADR-0051) and imposes nothing else. An image that lacks git simply fails the run's git ops — preflight checks resolvability, not contents.
  • Host panes are byte-for-byte unchanged. Same argv, same prlimit wrapper; the runner seam is strictly additive, and the "unsandboxed — full host access" label is the standing admission of what host mode is.
  • The lab dev sandbox cannot prove the loop. It has no rootless podman, so the container integration test skips there; argv/preflight construction is unit-proven, and the full container loop plus the in-container reachability assertions are maintainer-validated on the dev host (definition-of-done §11).
  • The follow-up seams are ready, not speculative. #206 (containerized login) and #207 (per-repo repos.image_ref) slot into the same runner seam and mount inventory; the tracer's single global image is the only thing #207 replaces.