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>
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: trueon themacos-containerbackend can rundocker compose up -d --waitagainst a stock compose file, pulling from a routed registry and serving from the workspace. - The agent's habits do not change:
dockeranddocker compose, notpodman. - 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
rootinside a nested container is the agent user outside it. This is for build and test workloads; the bottle stays the security boundary. - The
dockerandfirecrackerbackends. 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-containerslayers the Docker CLI, the compose plugin, and podman 5's networking stack —passt(pasta),netavark,nftables,aardvark-dns— plusfuse-overlayfs,slirp4netns, anduidmaponto 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; nonftand 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 sharedpreparetemplate.
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
dockerandfirecrackerbackends want an equivalent, or whether guest-local containers stay macOS-only.