feat(macos): run containers inside a bottle via guest-local podman

Adds the `nested_containers` bottle flag. On the macOS backend it starts
a rootless podman service inside the bottle and exposes its
Docker-compatible API socket, so the agent still runs `docker` and
`docker compose`. No host daemon socket is mounted and the guest gains
no capabilities; backends that cannot run a guest-local engine reject
the flag in the shared prepare template rather than ignoring it.

Podman rather than rootless Docker because Apple Container's capability
bounding set omits CAP_SYS_ADMIN, which the kernel requires to write a
multi-range uid_map via newuidmap. With no subordinate UID range podman
falls back to a single-UID self-mapping an unprivileged process may
write itself, so the image build strips /etc/subuid and /etc/subgid
entries rather than adding them.

That mapping is also why nested containers are not an isolation layer:
root inside one is the agent user outside it. They are a build/test
convenience; the bottle remains the boundary.

Ports the spike branch onto main, renaming docker_access — it implied
Docker and granted access to nothing on the host — and drops podman
from the derived layer now that every built-in image ships it (#451).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 15:39:11 -04:00
parent 8fed02fd1e
commit 29c46356ef
20 changed files with 1219 additions and 9 deletions
+10 -1
View File
@@ -15,7 +15,8 @@ def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
the same field-merge rules as the file-based extends machinery:
env: dict merge, later wins; git_user: per-field overlay, later
wins on non-empty; git (repos): union by name, later wins; egress
routes: concatenate; agent_provider, supervise: later replaces.
routes: concatenate; agent_provider, supervise,
nested_containers: later replaces.
"""
if not bottles:
raise ValueError("merge_bottles_runtime requires at least one bottle")
@@ -54,6 +55,7 @@ def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle
git_user=merged_git_user,
egress=merged_egress,
supervise=override.supervise,
nested_containers=override.nested_containers,
)
@@ -206,6 +208,7 @@ def _fold_two_bottles(
git_user=merged_git_user,
egress=merged_egress,
supervise=later.supervise,
nested_containers=later.nested_containers,
), merged_repos_raw
@@ -266,6 +269,11 @@ def _merge_bottles(
merged_supervise = (
child.supervise if "supervise" in child_raw else parent.supervise
)
merged_nested_containers = (
child.nested_containers
if "nested_containers" in child_raw
else parent.nested_containers
)
validate_egress_routes(name, merged_egress.routes)
return ManifestBottle(
@@ -275,6 +283,7 @@ def _merge_bottles(
git_user=merged_git_user,
egress=merged_egress,
supervise=merged_supervise,
nested_containers=merged_nested_containers,
)