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
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>
154 lines
7.5 KiB
Markdown
154 lines
7.5 KiB
Markdown
# 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`](../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.
|