Container Runtime

Container Runtime

Container parity with VM agentic-dev landed in `2026.5.0` under the #181 epic (issues #182–#186). The dashboard, REST surface, and AIWG bridge treat containers as first-class workloads alongside QEMU/KVM VMs — same lifecycle vocabulary, same loadout selector, same mission dispatch flow.

This document is the reference for operators picking a runtime and for integrators wiring container instances into the AIWG bridge. The Rust source of truth is `management/src/docker_runtime.rs`; the HTTP surface that wraps it is `management/src/http/containers.rs`.


Public API

`docker_runtime` is the single chokepoint for Docker shell-outs. Every container lifecycle operation funnels through these functions:

SymbolPurpose
`DockerMonitorConfig`Poll cadence + orphan-age threshold; loaded from env (`DOCKER_MONITOR_ENABLED`, `DOCKER_POLL_INTERVAL_SECS`, `DOCKER_ORPHANED_AGE_SECS`).
`ContainerInfo` / `ContainerStatus`Normalized `docker ps` row — `Running`, `Stopped`, or `Other(raw)`. `finished_at` populated for stopped containers.
`SpawnOpts``env: Vec<(String,String)>`, `labels: Vec<(key, value)>`, `mounts: Vec<(host, container)>`, `network: Option<String>`, `cmd: Vec<String>`, and optional unique `control_uid`.
`list_containers()``docker ps -a --filter label=agentic-sandbox=true`. Managed containers only — we never surface containers we did not spawn.
`spawn_container(name, image, opts)`Runs a platform-aware `docker run -d --label agentic-sandbox=true --name {name} …`. Linux adds the host-gateway mapping; Docker Desktop uses its native host DNS. Returns the container ID.
`start_container(name)` / `stop_container(name, timeout)`Idempotent lifecycle verbs over the same label-filtered set.
`remove_container(id)``docker rm -f` on a single ID.
`get_container_by_name(name)`Convenience lookup over `list_containers()`.
`spawn_docker_monitor(config, metrics, instance_registry, agent_registry)`Background task: polls every `poll_interval_secs`, emits `container.*` lifecycle events, revokes executor readiness when a container stops or disappears, and sweeps orphans older than `orphaned_age_secs`.

On Linux, the runtime adds `--add-host host.docker.internal:host-gateway`; without it the in-container agent's default `MANAGEMENT_SERVER=host.docker.internal:8120` does not resolve. On macOS, Docker Desktop provides `host.docker.internal` natively, so the runtime does not add the Linux-only mapping. Docker Desktop also rejects `network: host` at validation time because it cannot preserve Linux host-network semantics. Bind-mount source and destination paths must be absolute, and source paths must exist before Docker is invoked; Docker Desktop file-sharing denials return an actionable Settings path instead of raw daemon output.


Runtime selection: VM vs container

Both runtimes register against the same `OutputAggregator`, speak the same gRPC contract from `agent-rs`, and surface in the same dashboard sidebar. They differ where the substrate differs.

DimensionVM (QEMU/KVM)Container (Docker)
IsolationFull hardware virtualization. Kernel boundary between host and workload.Process namespace. Shared kernel.
Startup time30–90 s cold (cloud-init runs once); 5–15 s warm.1–3 s typical for `agentic/agent:dev`-derived images.
Resource overhead~512 MB RAM floor per VM (kernel + systemd + journald). Dedicated virtual disk.~50 MB RAM floor. Layered filesystem; no per-instance kernel.
NetworkLibvirt-managed bridge (`192.168.122.0/24` default). Per-VM IP. `agentshare` profile gets `--network none` for isolation.Docker bridge by default. Linux may use `--network host` and injects `host.docker.internal:host-gateway`; Docker Desktop uses native `host.docker.internal` and rejects host mode.
PersistenceDisk image survives `virsh destroy`; only `provision-vm.sh --destroy` wipes it.Container filesystem is ephemeral unless mounts are bound. Use `mounts: [(host_path, /workdir)]` for persistence.
AIWG framework installBaked into the cloud-init seed by `provision-vm.sh` via loadout.Baked into the image at build time; `claude` / `codex` / `opencode` images rebase onto `agentic/agent:dev`.
Operator escape hatch`virsh console`; direct `ssh agent@<ip>` only for dev/break-glass because it bypasses gateway policy/audit.`docker exec -it <name> bash`.
Crash recovery`crash_loop.rs` detector triggers `provision-vm.sh` rebuild. See `crash-loop.md`.Monitor sweeps stopped containers older than `orphaned_age_secs` (default 1 h). No auto-rebuild — operator decides.

When to pick a VM

  • The workload runs untrusted code, downloads arbitrary binaries, or

needs to exercise kernel features the container runtime forbids (raw sockets, ptrace of arbitrary PIDs, loading kernel modules).

  • The workload needs to survive container daemon restarts independent

of host reboot.

  • The mission persists for hours and the storage cost of a virtual

disk is acceptable.

  • The mission needs the `agentshare --network none` isolation tier

(forensics / red-team profiles).

When to pick a container

  • The workload is a short-lived agent task (minutes to ~1 h).
  • Fast iteration: rebuild image once, spawn dozens of fresh instances.
  • The toolchain in `agentic/agent:dev` is sufficient (Python via uv,

Node via fnm, Go, Rust via rustup, ripgrep/fd/bat/jq/delta/xh, cmake/ninja/meson, aider pinned to Python 3.12, `gh` + `gh copilot`).

  • The provider image (claude / codex / opencode) is one of the rebased

variants that already speak the agent protocol.


Image catalog

Container images are layered: a shared dev toolchain at the bottom, provider-specific images on top.

ImagePurposeBuilt from
`agentic/agent:dev`Shared dev toolchain layer. Mirrors the `agentic-dev` VM profile's `apt`/`uv`/`fnm`/`rustup` package set. /etc/profile.d snippet stabilizes PATH across login shells.Debian base + AIWG bootstrap. See `CHANGELOG.md` 2026.5.0 entry for #182.
`agentic/claude:latest`Claude Code CLI on top of `agentic/agent:dev`.Rebased onto shared base for parity (#183).
`agentic/codex:latest`OpenAI Codex CLI on top of `agentic/agent:dev`.Rebased onto shared base (#184).
`agentic/opencode:latest`OpenCode CLI on top of `agentic/agent:dev`.Rebased onto shared base (#185).
`agentic/automation-control:latest`Blueprint for orchestrator-driven TUI control sessions. Includes Codex, Aider, shared dev tools, and `agentic-provider-inventory` without bundling credentials.Extends `agentic/codex:latest` (#346).

The CI smoke matrix (#186) builds each image and asserts:

  • `python --version`, `node --version`, `go version`, `cargo --version`

all resolve.

  • `rg --version`, `fd --version`, `bat --version`, `jq --version`,

`xh --version`, `grpcurl --version` all resolve.

  • The agent binary inside the image dials the management server and

registers within the smoke window.

Automation-control blueprint

Use `agentic/automation-control:latest` when an external orchestrator needs a general-purpose sandbox session it can observe, search, and drive through the PTY control plane. The image intentionally does not embed secrets or auto-launch provider login flows from global env. Start with the credential-free probe, then use the inventory and readiness helpers before starting a managed provider TUI:

agentic-provider-inventory
agentic-provider-readiness codex
agentic-codex-automation
agentic-claude-automation

`agentic-codex-automation` prefers `OPENAI_API_KEY_FILE` or `AGENTIC_CREDENTIAL_DIR/openai_api_key`, then sets `OPENAI_API_KEY` only in the final provider process. `agentic-claude-automation` does the same for `ANTHROPIC_API_KEY_FILE` or `AGENTIC_CREDENTIAL_DIR/anthropic_api_key`. Both wrappers support `AGENTIC_PROVIDER_HOME` for isolated provider home/config/cache directories.

`agentic-provider-readiness` emits structured tab-separated readiness rows: provider, CLI presence/version, auth state, and error class. It does not print credential values.

Then launch provider TUIs only after the orchestrator has satisfied its credential and Controller-input policy gates. The target model for automated provider launch is ADR-028: startup profiles reference credential ids, the credential broker issues session-scoped leases, and provider launchers consume leased files from a per-session credential directory. Managed Docker refuses startup profiles containing raw credential refs. Use the credential proxy for supported protocols or QEMU when a provider requires local raw material; image-baked credentials and `docker run -e` provider tokens remain outside the managed security boundary.


REST surface

Container lifecycle lives at `/api/v1/containers/`, mirroring the shape of `/api/v1/vms/`. The full list is in `management/src/http/containers.rs`; the relevant endpoints are:

  • `GET /api/v1/containers` — list managed containers.
  • `POST /api/v1/containers` — create + spawn. With no explicit transport,

management mounts its UDS, registers a unique per-instance control UID, and starts workload children as capability-free UID/GID `10001:10001`. No bootstrap token or mTLS key is issued. An explicitly supplied transport is a compatibility path and does not receive the split-identity claim.

  • `GET /api/v1/containers/{name}` — single-container detail.
  • `POST /api/v1/containers/{name}/start` — start a stopped container.
  • `POST /api/v1/containers/{name}/stop` — graceful stop with timeout.
  • `DELETE /api/v1/containers/{name}` — `docker rm -f`.

The image catalog endpoint (`GET /api/v1/container-images` → `management/src/http/container_images.rs:56`) returns the curated provider image set the dashboard offers in the Create dialog.

PTY exec inside a container is not part of this surface — that lives behind the `pty-ws/v1` binding (see `pty-rendering.md`) which attaches to whatever the container entrypoint produces via the existing in-container agent path.


AIWG bridge integration

When `AIWG_SERVE_ENDPOINT` is set, the management server registers itself as an A2A executor (see `aiwg-executor.md`). Mission dispatch lands at `POST /api/v1/sessions/:id/dispatch` and routes to either a VM or a container depending on the session's recorded runtime.

  • The bridge does not know or care which runtime backs an

instance. It addresses by `(instance_id, session_id)`.

  • The dispatch handler resolves the instance to its runtime, then

invokes the matching session-attach path.

  • Container instances participate in the same `mission.*` event

vocabulary the executor contract emits — `mission.dispatched`, `mission.completed`, `mission.failed`. The events stream over the same `/ws/executors/{id}` channel.

The 2026.5.0 `server_hello` capability banner (#190) advertises both runtimes; AIWG's `replayCapable` gate flips on for either.


Loadout interaction

Today's published loadout profiles (`images/qemu/profiles/`) are VM-only — `agentic-dev.yaml`, `agentic-dev-cloud-init.yaml`. The loadout schema described in `LOADOUTS.md` is reused for container instances by setting an explicit runtime on the manifest. Container "loadouts" today are effectively the choice of image (for example `agentic/claude:latest`, `agentic/codex:latest`, `agentic/opencode:latest`, or `agentic/automation-control:latest`) plus mounts and env; the formal `runtime:` field unifies that with the VM profile syntax.

When provisioning a container from the dashboard:

1. Pick Container in the Runtime dropdown of the Unified Instances Create dialog (#178). 2. Pick an image from the curated `agentic/*:latest` provider list. 3. Optionally add bind mounts (host path → `/workdir`-style container path) for persistence. v2 admin Docker provision accepts `mounts` as `host_path:container_path` strings. 4. The dashboard issues `POST /api/v1/containers`; management injects the instance-bound UDS transport and split control/workload identity.

For v2 admin Docker provision, `agentshare: true` creates a per-instance host tree under `AGENTSHARE_ROOT` or `/srv/agentshare`:

{AGENTSHARE_ROOT}/instances/{instance_id}/
├── workspace/
├── inbox/
├── outbox/
└── comms/

The Docker runtime bind-mounts that tree into the container at the canonical work paths clients expect:

Host subdirectoryContainer paths
`workspace/``/workspace`, `/root/workspace`
`inbox/``/mnt/inbox`, `/root/inbox`, `/inbox`
`outbox/``/mnt/outbox`, `/root/outbox`, `/outbox`
`comms/``/mnt/comms`, `/root/comms`, `/comms`

If a caller supplies an explicit mount for one of those container paths, that mount wins and the default for that path is skipped. Docker AgentCards advertise `adapter-command/v1` only when a workspace mount is available, so orchestrators can treat the extension as a live capability contract rather than an unconditional server feature.

Admin v2 inventory exposes container bootstrap readiness separately from runtime metadata. Running containers without a Ready agent report `operation_status: "bootstrap_pending"` with `agent_registered: false` and `agent_ready: false`; stopped containers that exited before registration report `operation_status: "not_ready"` and include `container_finished_at` when Docker provides it. Cockpit and other bridge consumers should poll these fields before creating sessions instead of assuming an AgentCard URL implies a usable agent.


Operational notes

  • Orphan cleanup is enabled by default. Set

`DOCKER_MONITOR_ENABLED=false` to disable the background monitor and sweep. The monitor is scoped by the `agentic-sandbox=true` Docker label, not a name prefix, and removes stopped managed containers after `DOCKER_ORPHANED_AGE_SECS` (default 3600). Do not apply that label to operator-owned containers that management must not lifecycle-manage.

  • Docker process state is negative readiness evidence only. A stopped or

disappeared container immediately revokes executor readiness. A running container does not become dispatchable until its agent completes authenticated registration.

  • Stop ≠ delete. The dashboard's Stop button calls

`POST /api/v1/containers/{name}/stop` and leaves the container in Stopped state so the operator can restart or inspect it. Force-off goes through the same path with timeout 0.

  • Container metrics flow into the same `Metrics` aggregator as VM

metrics (`management/src/telemetry/metrics.rs`). See `telemetry.md` for the label scheme.

  • Lifecycle events emit through the same SSE stream as VM events

(`/api/v1/events?follow=true`). See `transport-audit.md`.


See also

container `runtime:` field per #178).

`pty-ws/v1` binding (works against containers via in-container agent).

(container parity is operator-driven for now).

— observability for either runtime.

  • `CHANGELOG.md` — 2026.5.0 entry for the #181 epic.

Managed UDS restart recovery

New Linux managed containers mount a dedicated socket-only directory read-only. The configured host UDS path stays available; management atomically publishes its bound socket into `<socket-path>.containers/agent-grpc.sock`. Rebinding after a management restart changes the socket entry inside the stable directory, so running containers reconnect without a Docker restart. The parent directory of the configured socket, including any secrets stored there, is never mounted.

Provisioning records `agentic-control-domain` alongside the existing instance, control UID and workload-boundary labels. Before accepting UDS connections, management restores identities only from Docker records belonging to its own stable trust domain. The deterministic control UID and separated workload UID must match; conflicting or malformed scoped records fail startup. Unknown UIDs and foreign-domain containers remain unauthorized. This recovery does not depend on the Docker cleanup monitor. If Docker metadata cannot be read at startup, recovery is explicitly unavailable and unmapped agents remain rejected.

Containers created before this change have socket-file mounts and no scoped recovery label. Recreate them through the managed provision API once to adopt this behavior. Do not add labels by hand or relax UID validation as a recovery workaround. Preserve the management identity/secrets directory across restart; a new management identity intentionally does not adopt another domain's peers.