Files
bot-bottle/docs/prds/prd-new-nested-containers.md
T
didericis 913bcab6a0
test / unit (pull_request) Failing after 33s
tracker-policy-pr / check-pr (pull_request) Failing after 34s
test / integration-docker (pull_request) Successful in 42s
test / integration-firecracker (pull_request) Successful in 4m4s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
docs(nested-containers): record what live verification established
Documents the proxy-layer precedence that took four attempts to get
right, the blob-CDN routes every registry needs alongside its own, the
podman 5 networking packages and how each one fails when absent, and
the BusyBox wget caveat. Closes the DNS open question: public
resolution inside a nested container fails by design; reaching the
proxy was the real problem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:25:27 -04:00

7.5 KiB

PRD prd-new: Containers inside a bottle

  • Status: Draft
  • Author: Claude
  • Created: 2026-07-21
  • Issue: #392

Summary

Let an agent run docker and docker compose inside its bottle by starting a guest-local rootless podman service that exposes a Docker-compatible API socket. Gated per bottle by nested_containers: true. No host daemon socket is mounted and no capability is added to the guest, on any backend.

Problem

Agent tasks routinely involve containers: docker compose up to verify a scaffolded stack, a throwaway database for a test run, building an image to check a Dockerfile works. Bottles have no way to do any of that, so those tasks either fail or get done outside the bottle — which defeats the point of the bottle.

Goals / success criteria

  • A bottle with nested_containers: true on the macos-container backend can run docker compose up -d --wait against a stock compose file, pulling from a routed registry and serving from the workspace.
  • The agent's habits do not change: docker and docker compose, not podman.
  • No host Docker socket is mounted, on any backend.
  • No capability is added to the Apple Container guest.
  • Backends without a guest-local engine reject the flag with a clear error rather than ignoring it.
  • Bottles that do not set the flag carry none of its cost.

Non-goals

  • Nested containers as an isolation layer. The single-UID mapping means root inside a nested container is the agent user outside it. This is for build and test workloads; the bottle stays the security boundary.
  • The docker and firecracker backends. Both reject the flag for now.
  • Reaching registries that are not routed through the bottle's egress proxy.

Design

Why podman, not rootless Docker

Apple Container's capability bounding set omits CAP_SYS_ADMIN, which the kernel requires in order to write a multi-range uid_map via newuidmap. Rootless Docker has no path that avoids that write, so it cannot run in a bottle without granting CAP_SYS_ADMIN — which is close to root in practical terms and gives up most of what the bottle is for.

Podman does have a path: with no subordinate UID range configured it falls back to a single-UID self-mapping, which an unprivileged process may write itself. The image build therefore removes the agent user's /etc/subuid and /etc/subgid entries rather than adding them — their presence is exactly what would send podman down the newuidmap path. Full negative result in docs/research/rootless-docker-in-apple-container-spike.md.

The flag, and its name

The flag is kept because it gates real costs, not because podman needs a privilege grant: ~100MB of derived image, a resident service per bottle, and relaxed modes on /dev/fuse and /dev/net/tun that the majority of bottles should not get.

It is named nested_containers, not docker_access. It implies no Docker and grants access to nothing on the host — naming it after Docker access would describe the one thing the design refuses to do.

Shape

  • bot_bottle/backend/macos_container/nested_containers.py — derived-image build, guest env, device preparation, service start/readiness.
  • bot_bottle/backend/macos_container/nested-containers-init.sh — the unprivileged bootstrap that runs inside the bottle. Fails closed on every prerequisite it needs (podman, the storage/network helpers, writable device nodes, an empty subordinate range) rather than degrading.
  • The derived image …-nested-containers layers the Docker CLI, the compose plugin, and podman 5's networking stack — passt (pasta), netavark, nftables, aardvark-dns — plus fuse-overlayfs, slirp4netns, and uidmap onto the agent image. Podman itself already ships in every built-in image (#451), but with --no-install-recommends, so none of the networking pieces arrive with it. Each absence fails at a different and misleading layer: no pasta and nothing starts; no nft and containers are created but never start; no aardvark-dns and DNS inside a container fails while everything else looks healthy. Image pulls keep working throughout, which is what makes these read as compat-API bugs.
  • BottleBackend.supports_nested_containers — false by default, so a backend that cannot honor the flag fails in the shared prepare template.

Guest configuration the single-UID mapping forces: ignore_chown_errors (no second UID for layers to be chowned to), cgroups="disabled" and cgroup_manager="cgroupfs" (no cgroup delegation reaches the guest), events_logger="file" (no journald socket).

Registry reach

Image pulls egress through the bottle's proxy like everything else, so each registry needs a route — and so does the CDN it redirects blobs to, which is a separate host: production.cloudfront.docker.com for Docker Hub, pkg-containers.githubusercontent.com for GHCR, cdn0*.quay.io for quay. Without those the pull authenticates, fetches the manifest, and then 403s partway through. Docker Hub and GHCR need preserve_auth: true — their token dance uses a client-fetched per-scope bearer token that the proxy would otherwise strip. Layer pulls should also set dlp.outbound_detectors: false and dlp.inbound_detectors: false: body scanning on a multi-hundred-MB layer is what triggers #455, and a registry route's scanned bodies are compressed layer blobs rather than anything a detector can read.

Reaching the network from a nested container

Public DNS inside a nested container fails by design: everything egresses through the proxy. What was actually broken was reaching the proxy at all, and it took four attempts to fix because podman applies proxy settings at several layers and the last writer wins:

Layer Applies to
~/.docker/config.json proxies block every container the Docker CLI starts — client-side, beats everything below
service environment podman's own pulls, non-CLI API clients
containers.conf env native podman run
containers.conf hosts_file, http_proxy native podman run only — the compat API ignores both

The gateway is named bot-bottle-gateway, which resolves only through the bottle's /etc/hosts; a nested container gets its own. Since no config key reaches the compat path, the name is resolved in the bottle and the address is substituted into the proxy URL at each layer above. NO_PROXY keeps the name, which is matched against what a client asks for.

The gateway TLS-intercepts, so the CA bundle is mounted read-only and SSL_CERT_FILE, CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE, and NODE_EXTRA_CA_CERTS point at it — no distro-specific trust commands.

Verified on macOS 26 / Apple Container 1.0.0 / podman 5.4.2

With plain docker, no extra flags: image pulls from Docker Hub, GHCR, and quay; docker run with correct exit-code propagation; docker compose up, logs, down, and a published port curl'd from the bottle; curl https://quay.io and https://pypi.org/simple/ returning 200 from inside a nested container; and https://example.com returning 403 — the egress allowlist applies to nested containers, not just the bottle.

Alpine's BusyBox wget drops the connection after TLS interception and reports "error getting response". The proxy logs the decrypted request, and curl on the same host succeeds, so this is a BusyBox client limitation rather than anything in the egress path.

Open questions

  • Whether the docker and firecracker backends want an equivalent, or whether guest-local containers stay macOS-only.