feat(macos): replace rootless Docker spike with rootless podman
test / stage-firecracker-inputs (pull_request) Successful in 2s
test / integration-docker (pull_request) Successful in 32s
test / unit (pull_request) Successful in 36s
lint / lint (push) Failing after 52s
test / build-infra (pull_request) Successful in 4m5s
tracker-policy-pr / check-pr (pull_request) Successful in 8s
test / integration-firecracker (pull_request) Successful in 2m2s
test / coverage (pull_request) Successful in 2m7s
test / publish-infra (pull_request) Has been skipped

Podman avoids the CAP_SYS_ADMIN requirement that makes rootless Docker
impossible here: with no subordinate UID range configured it falls back
to a single-UID self-mapping, which an unprivileged process may write
itself, so newuidmap is never invoked. The image build therefore strips
/etc/subuid and /etc/subgid entries rather than adding them.

The agent-facing surface is unchanged — docker and docker compose talk
to podman's Docker-compatible API socket.

Two device nodes need relaxing as root inside the bottle (0666 on
/dev/fuse and /dev/net/tun); both already exist and neither needs a
capability the bottle lacks, unlike CAP_SYS_ADMIN.

Verified live on macOS 26 / Apple Container 1.0.0: service starts with
zero added capabilities, compose pulls and serves from the workspace on
a published port, and a nested container cannot reach the network
outside the egress path.

Known limitation, not yet addressed: the agent base image is Debian
bookworm, whose podman 4.3.1 swallows container exit codes through the
compat API (docker run returns 0 regardless). podman 5.4.2 on trixie
fixes it; the base bump is a separate PR. The acceptance test asserts
on an in-band marker rather than an exit code so it cannot false-pass
in the meantime.

Nested containers give no isolation from the agent itself — root inside
one is the agent user outside it. They are a build/test convenience;
the bottle remains the security boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnMp8SUGH57hZX7192Rv2F
This commit is contained in:
2026-07-21 12:59:10 -04:00
parent 6a22b9f654
commit 55def3732e
10 changed files with 454 additions and 294 deletions
+14 -6
View File
@@ -75,13 +75,21 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend.
> **Experimental Docker-in-bottle spike (#392):** a bottle may set
> **Experimental containers-in-bottle spike (#392):** a bottle may set
> `docker_access: true`. On the macOS backend this starts a guest-local,
> rootless Docker daemon after the bottle is registered. It does not mount
> Docker Desktop's socket or add outer VM capabilities. The spike currently
> requires live-macOS validation before it is considered supported; startup
> fails closed when the Apple guest kernel cannot satisfy rootless Docker's
> user-namespace requirements.
> rootless **podman** service after the bottle is registered, exposing its
> Docker-compatible API socket — the agent still uses `docker` and `docker
> compose`. It does not mount Docker Desktop's socket or add outer VM
> capabilities. Rootless Docker was tried first and does not work here at
> all: Apple Container's capability bounding set omits `CAP_SYS_ADMIN`,
> which the kernel requires to write a multi-range `uid_map`. See
> [`docs/research/rootless-docker-in-apple-container-spike.md`](docs/research/rootless-docker-in-apple-container-spike.md).
>
> The tradeoff to understand before enabling it: podman avoids that
> requirement by falling back to a single-UID mapping, so nested containers
> provide **no isolation from the agent itself** — `root` inside a nested
> container is the agent user outside it. Nested containers are a build/test
> convenience, not a security boundary. The bottle remains the boundary.
### Firecracker on Linux
+7 -4
View File
@@ -64,7 +64,7 @@ from .gateway_hosts import (
refresh_gateway_host,
set_gateway_host,
)
from . import rootless_docker
from . import rootless_podman
from .bottle_plan import MacosContainerBottlePlan
from ...orchestrator.config_store import resolve_teardown_timeout
from .consolidated_launch import (
@@ -174,7 +174,7 @@ def launch(
exec_env = {
**_identity_proxy_env(endpoint, ctx.identity_token),
**rootless_docker.guest_env(plan.docker_access),
**rootless_podman.guest_env(plan.docker_access),
}
bottle = MacosContainerBottle(
plan.container_name,
@@ -194,7 +194,10 @@ def launch(
bottle.prompt_path = provision(plan, bottle)
if plan.docker_access:
rootless_docker.start(bottle)
rootless_podman.prepare_guest_devices(
plan.container_name, container_mod.exec_container_as_root,
)
rootless_podman.start(bottle)
yield bottle
finally:
@@ -218,7 +221,7 @@ def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path,
)
if plan.docker_access:
image = rootless_docker.build_image(plan.image, container_mod.build_image)
image = rootless_podman.build_image(plan.image, container_mod.build_image)
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=image),
@@ -1,57 +0,0 @@
#!/bin/sh
set -eu
uid="$(id -u)"
if [ "$uid" -eq 0 ]; then
echo "refusing to run rootless Docker as root" >&2
exit 1
fi
for command in dockerd-rootless.sh rootlesskit slirp4netns newuidmap newgidmap docker; do
command -v "$command" >/dev/null 2>&1 || {
echo "missing rootless Docker prerequisite: $command" >&2
exit 1
}
done
grep -q "^$(id -un):.*:65536$" /etc/subuid || {
echo "missing 65536-entry subordinate UID range for $(id -un)" >&2
exit 1
}
grep -q "^$(id -gn):.*:65536$" /etc/subgid || {
echo "missing 65536-entry subordinate GID range for $(id -gn)" >&2
exit 1
}
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bot-bottle-docker-run}"
mkdir -p "$XDG_RUNTIME_DIR" "$HOME/.docker"
chmod 700 "$XDG_RUNTIME_DIR"
# Docker uses this config for build and child-container proxy injection. The
# token-bearing proxy URL is already available to the agent; persisting it
# inside this disposable VM does not broaden its authority.
python3 - <<'PY'
import json
import os
from pathlib import Path
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy", "")
no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy", "")
config = {"proxies": {"default": {
"httpProxy": proxy,
"httpsProxy": proxy,
"noProxy": no_proxy,
}}}
path = Path.home() / ".docker" / "config.json"
path.write_text(json.dumps(config), encoding="utf-8")
path.chmod(0o600)
PY
if docker info >/dev/null 2>&1; then
exit 0
fi
log=/tmp/bot-bottle-rootless-docker.log
nohup dockerd-rootless.sh \
--storage-driver=fuse-overlayfs \
>"$log" 2>&1 </dev/null &
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
set -eu
uid="$(id -u)"
if [ "$uid" -eq 0 ]; then
echo "refusing to run rootless podman as root" >&2
exit 1
fi
for command in podman docker fuse-overlayfs slirp4netns; do
command -v "$command" >/dev/null 2>&1 || {
echo "missing rootless podman prerequisite: $command" >&2
exit 1
}
done
# The inverse of the rootless-Docker check, and the whole point of the podman
# variant: a subordinate range would push podman onto newuidmap, which cannot
# write a multi-range uid_map without CAP_SYS_ADMIN in this guest. An empty
# range keeps it on the single-UID self-mapping an unprivileged process may
# write itself.
if grep -q "^$(id -un):" /etc/subuid 2>/dev/null; then
echo "unexpected subordinate UID range for $(id -un): podman would" >&2
echo "require CAP_SYS_ADMIN via newuidmap in this guest" >&2
exit 1
fi
for device in /dev/fuse /dev/net/tun; do
[ -r "$device" ] && [ -w "$device" ] || {
echo "device $device is not readable/writable by $(id -un)" >&2
exit 1
}
done
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bot-bottle-podman-run}"
config="$HOME/.config/containers"
mkdir -p "$XDG_RUNTIME_DIR" "$config"
chmod 700 "$XDG_RUNTIME_DIR"
# ignore_chown_errors is required, not incidental: with a single-UID mapping
# there is no second UID for image layers to be chowned to, so layers that
# record other owners would otherwise fail to extract.
cat > "$config/storage.conf" <<'CONF'
[storage]
driver="overlay"
[storage.options.overlay]
mount_program="/usr/bin/fuse-overlayfs"
ignore_chown_errors="true"
CONF
# No cgroup delegation reaches this guest, so asking podman to manage cgroups
# fails; events_logger=file avoids the journald socket that is equally absent.
cat > "$config/containers.conf" <<'CONF'
[containers]
cgroups="disabled"
[engine]
cgroup_manager="cgroupfs"
events_logger="file"
CONF
# Registry pulls egress through the bottle's proxy like everything else. The
# token-bearing proxy URL is already in the agent's environment; persisting it
# inside this disposable VM does not broaden its authority.
python3 - <<'PY'
import json
import os
from pathlib import Path
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy", "")
no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy", "")
config = {"proxies": {"default": {
"httpProxy": proxy,
"httpsProxy": proxy,
"noProxy": no_proxy,
}}}
path = Path.home() / ".docker" / "config.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(config), encoding="utf-8")
path.chmod(0o600)
PY
if docker info >/dev/null 2>&1; then
exit 0
fi
log=/tmp/bot-bottle-rootless-podman.log
nohup podman system service --time=0 \
"unix://$XDG_RUNTIME_DIR/podman.sock" \
>"$log" 2>&1 </dev/null &
@@ -1,94 +0,0 @@
"""Experimental rootless Docker bootstrap for Apple-container bottles.
The daemon and every child remain inside the existing per-bottle VM. This
module deliberately refuses to compensate for missing prerequisites with
outer capabilities, a privileged container, or a host Docker socket.
"""
from __future__ import annotations
import shlex
import shutil
import tempfile
import time
from pathlib import Path
from typing import Callable
from ...log import die, info
_INIT = "/usr/local/libexec/bot-bottle/rootless-docker-init"
_RUNTIME_DIR = "/tmp/bot-bottle-docker-run"
_SOCKET = f"{_RUNTIME_DIR}/docker.sock"
READY_RETRIES = 30
def build_image(
base_image: str,
build: Callable[..., None],
) -> str:
"""Layer spike-only tooling on an already-built provider image."""
image = f"{base_image}-rootless-docker"
init_script = Path(__file__).with_name("rootless-docker-init.sh")
with tempfile.TemporaryDirectory(prefix="bot-bottle-rootless-docker.") as tmp:
context = Path(tmp)
shutil.copy2(init_script, context / "rootless-docker-init.sh")
(context / "Dockerfile").write_text(
"FROM docker:28-cli AS docker_cli\n"
f"FROM {base_image}\n"
"USER root\n"
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
"docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose\n"
"RUN apt-get update \\\n"
" && apt-get install -y --no-install-recommends docker.io uidmap "
"rootlesskit slirp4netns fuse-overlayfs \\\n"
" && rm -rf /var/lib/apt/lists/* \\\n"
" && sed -i '/^node:/d' /etc/subuid /etc/subgid \\\n"
" && printf 'node:100000:65536\\n' >> /etc/subuid \\\n"
" && printf 'node:100000:65536\\n' >> /etc/subgid \\\n"
" && ln -s /usr/share/docker.io/contrib/dockerd-rootless.sh "
"/usr/local/bin/dockerd-rootless.sh\n"
"COPY rootless-docker-init.sh "
"/usr/local/libexec/bot-bottle/rootless-docker-init\n"
"RUN chmod 0755 /usr/local/libexec/bot-bottle/rootless-docker-init\n"
"USER node\n",
encoding="utf-8",
)
build(image, str(context), dockerfile=str(context / "Dockerfile"))
return image
def guest_env(enabled: bool) -> dict[str, str]:
"""Environment consumed by the Docker CLI inside an enabled bottle."""
if not enabled:
return {}
return {
"DOCKER_HOST": f"unix://{_SOCKET}",
"XDG_RUNTIME_DIR": _RUNTIME_DIR,
}
def start(bottle: object) -> None:
"""Start and verify the unprivileged daemon through the bottle exec API."""
info("starting experimental rootless Docker daemon")
result = bottle.exec(shlex.quote(_INIT)) # type: ignore[attr-defined]
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
die(f"rootless Docker bootstrap failed: {detail or '<no output>'}")
for _ in range(READY_RETRIES):
result = bottle.exec("docker info >/dev/null 2>&1") # type: ignore[attr-defined]
if result.returncode == 0:
info("rootless Docker daemon is ready")
return
time.sleep(0.2)
logs = bottle.exec( # type: ignore[attr-defined]
"tail -n 80 /tmp/bot-bottle-rootless-docker.log 2>/dev/null || true"
)
die(
"rootless Docker did not become ready without additional outer "
f"privileges:\n{(logs.stdout or logs.stderr or '<no log>').strip()}"
)
__all__ = ["build_image", "guest_env", "start"]
@@ -0,0 +1,132 @@
"""Experimental rootless podman bootstrap for Apple-container bottles.
The service and every nested container remain inside the existing per-bottle
VM. This module refuses to compensate for missing prerequisites with outer
capabilities, a privileged container, or a host Docker socket.
Podman is used rather than rootless Docker for one specific reason: Apple
Container's capability bounding set omits `CAP_SYS_ADMIN`, which the kernel
requires to write a multi-range `uid_map` via `newuidmap`. Rootless Docker
has no path that avoids that write. Podman does — with no subordinate UID
range configured it falls back to a single-UID self-mapping, which an
unprivileged process may write itself. See
`docs/research/rootless-docker-in-apple-container-spike.md`.
That fallback is why `build_image` *removes* the agent user's `/etc/subuid`
and `/etc/subgid` entries instead of adding them: their presence is precisely
what would send podman down the `newuidmap` path that cannot work here.
The agent still talks to `docker` and `docker compose`; those speak to
podman's Docker-compatible API socket, so nothing in the agent's habits
changes.
"""
from __future__ import annotations
import shlex
import shutil
import tempfile
import time
from pathlib import Path
from typing import Callable
from ...log import die, info
_INIT = "/usr/local/libexec/bot-bottle/rootless-podman-init"
_RUNTIME_DIR = "/tmp/bot-bottle-podman-run"
_SOCKET = f"{_RUNTIME_DIR}/podman.sock"
_LOG = "/tmp/bot-bottle-rootless-podman.log"
READY_RETRIES = 30
# Apple Container creates both device nodes 0600 root:root, so the agent user
# cannot open them: /dev/fuse blocks the fuse-overlayfs storage driver and
# /dev/net/tun blocks slirp4netns, which rootless podman uses for the default
# bridge network that stock compose files expect. Relaxing the modes needs no
# capability the bottle does not already hold — unlike CAP_SYS_ADMIN, which is
# what killed the rootless-Docker approach.
_GUEST_DEVICES = ("/dev/fuse", "/dev/net/tun")
def build_image(
base_image: str,
build: Callable[..., None],
) -> str:
"""Layer spike-only tooling on an already-built provider image."""
image = f"{base_image}-rootless-podman"
init_script = Path(__file__).with_name("rootless-podman-init.sh")
with tempfile.TemporaryDirectory(prefix="bot-bottle-rootless-podman.") as tmp:
context = Path(tmp)
shutil.copy2(init_script, context / "rootless-podman-init.sh")
(context / "Dockerfile").write_text(
"FROM docker:28-cli AS docker_cli\n"
f"FROM {base_image}\n"
"USER root\n"
"COPY --from=docker_cli /usr/local/bin/docker /usr/local/bin/docker\n"
"COPY --from=docker_cli /usr/local/libexec/docker/cli-plugins/"
"docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose\n"
"RUN apt-get update \\\n"
" && apt-get install -y --no-install-recommends podman "
"fuse-overlayfs slirp4netns uidmap \\\n"
" && rm -rf /var/lib/apt/lists/* \\\n"
# Deliberate: an empty subordinate range keeps podman on the
# single-UID mapping that needs no CAP_SYS_ADMIN. Adding ranges
# here would reintroduce the newuidmap failure this spike exists
# to route around.
" && sed -i '/^node:/d' /etc/subuid /etc/subgid\n"
"COPY rootless-podman-init.sh "
"/usr/local/libexec/bot-bottle/rootless-podman-init\n"
"RUN chmod 0755 /usr/local/libexec/bot-bottle/rootless-podman-init\n"
"USER node\n",
encoding="utf-8",
)
build(image, str(context), dockerfile=str(context / "Dockerfile"))
return image
def guest_env(enabled: bool) -> dict[str, str]:
"""Environment consumed by the Docker CLI inside an enabled bottle."""
if not enabled:
return {}
return {
"DOCKER_HOST": f"unix://{_SOCKET}",
"XDG_RUNTIME_DIR": _RUNTIME_DIR,
}
def prepare_guest_devices(container_name: str, exec_as_root: Callable[..., None]) -> None:
"""Make /dev/fuse and /dev/net/tun openable by the agent user.
Runs as root inside the bottle because the agent must not be able to
re-mode device nodes itself. No outer capability is involved.
"""
exec_as_root(
container_name,
["sh", "-c", f"chmod 0666 {' '.join(_GUEST_DEVICES)}"],
)
def start(bottle: object) -> None:
"""Start and verify the unprivileged service through the bottle exec API."""
info("starting experimental rootless podman service")
result = bottle.exec(shlex.quote(_INIT)) # type: ignore[attr-defined]
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
die(f"rootless podman bootstrap failed: {detail or '<no output>'}")
for _ in range(READY_RETRIES):
result = bottle.exec("docker info >/dev/null 2>&1") # type: ignore[attr-defined]
if result.returncode == 0:
info("rootless podman service is ready")
return
time.sleep(0.2)
logs = bottle.exec( # type: ignore[attr-defined]
f"tail -n 80 {_LOG} 2>/dev/null || true"
)
die(
"rootless podman did not become ready without additional outer "
f"privileges:\n{(logs.stdout or logs.stderr or '<no log>').strip()}"
)
__all__ = ["build_image", "guest_env", "prepare_guest_devices", "start"]
@@ -1,103 +0,0 @@
"""Live-Mac acceptance spike for guest-local rootless Docker (issue #392).
Run explicitly on an Apple Silicon/macOS 26 host:
BOT_BOTTLE_ROOTLESS_DOCKER_SPIKE=1 \
python3 -m unittest tests.integration.test_macos_rootless_docker_spike -v
The opt-in is deliberate: ordinary Linux CI cannot execute Apple Container.
"""
from __future__ import annotations
import os
import platform
import shutil
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.manifest import ManifestIndex
@unittest.skipUnless(
platform.system() == "Darwin"
and os.environ.get("BOT_BOTTLE_ROOTLESS_DOCKER_SPIKE") == "1",
"requires an explicit live-Mac rootless-Docker spike run",
)
class TestMacosRootlessDockerSpike(unittest.TestCase):
def test_compose_stays_inside_registered_bottle(self) -> None:
workspace = Path(tempfile.mkdtemp(prefix="rootless-docker-spike."))
stage = Path(tempfile.mkdtemp(prefix="rootless-docker-stage."))
try:
(workspace / "index.html").write_text("bottle-compose-ok\n")
(workspace / "compose.yaml").write_text(
"services:\n"
" web:\n"
" image: python:3.12-alpine\n"
" working_dir: /workspace\n"
" command: python -m http.server 8000\n"
" volumes: ['.:/workspace']\n"
" ports: ['18080:8000']\n",
encoding="utf-8",
)
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": {
"docker_access": True,
"egress": {"routes": [
{"host": "auth.docker.io"},
{"host": "registry-1.docker.io"},
{"host": "production.cloudflare.docker.com"},
]},
}},
"agents": {"spike": {
"bottle": "dev", "skills": [], "prompt": "",
}},
})
spec = BottleSpec(
manifest=manifest,
agent_name="spike",
copy_cwd=True,
user_cwd=str(workspace),
)
backend = get_bottle_backend("macos-container")
plan = backend.prepare(spec, stage_dir=stage)
with backend.launch(plan) as bottle:
workdir = plan.workspace_plan.workdir
checks = (
"docker info >/dev/null && docker compose version && "
f"cd {workdir} && docker compose up -d --wait && "
"curl --fail --silent http://127.0.0.1:18080/ | "
"grep -q bottle-compose-ok"
)
result = bottle.exec(checks)
self.assertEqual(
0, result.returncode,
f"stdout={result.stdout!r}\nstderr={result.stderr!r}",
)
inspect = bottle.exec(
"docker info --format '{{json .SecurityOptions}}'"
)
self.assertIn("rootless", inspect.stdout.lower())
self.assertNotEqual(
0,
bottle.exec("test -S /var/run/docker.sock").returncode,
"spike must never expose a host/rootful Docker socket",
)
direct = bottle.exec(
"docker run --rm --env HTTP_PROXY= --env HTTPS_PROXY= "
"--env http_proxy= --env https_proxy= python:3.12-alpine "
"wget -T 4 -qO- https://evil.example.com/"
)
self.assertNotEqual(
0, direct.returncode,
"an inner container obtained direct, unproxied egress",
)
finally:
shutil.rmtree(workspace, ignore_errors=True)
shutil.rmtree(stage, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,154 @@
"""Live-Mac acceptance spike for guest-local rootless podman (issue #392).
Run explicitly on an Apple Silicon/macOS 26 host:
BOT_BOTTLE_ROOTLESS_PODMAN_SPIKE=1 \
python3 -m unittest tests.integration.test_macos_rootless_podman_spike -v
The opt-in is deliberate: ordinary Linux CI cannot execute Apple Container.
Podman rather than Docker because Apple Container's capability bounding set
omits CAP_SYS_ADMIN; see
docs/research/rootless-docker-in-apple-container-spike.md. The agent-facing
surface is still `docker` and `docker compose`, which talk to podman's
Docker-compatible API socket.
"""
from __future__ import annotations
import os
import platform
import shutil
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.manifest import ManifestIndex
@unittest.skipUnless(
platform.system() == "Darwin"
and os.environ.get("BOT_BOTTLE_ROOTLESS_PODMAN_SPIKE") == "1",
"requires an explicit live-Mac rootless-podman spike run",
)
class TestMacosRootlessPodmanSpike(unittest.TestCase):
def test_compose_stays_inside_registered_bottle(self) -> None:
workspace = Path(tempfile.mkdtemp(prefix="rootless-podman-spike."))
stage = Path(tempfile.mkdtemp(prefix="rootless-podman-stage."))
try:
(workspace / "index.html").write_text("bottle-compose-ok\n")
(workspace / "compose.yaml").write_text(
"services:\n"
" web:\n"
" image: quay.io/prometheus/busybox\n"
" working_dir: /workspace\n"
" command: httpd -f -p 8000 -h /workspace\n"
" volumes: ['.:/workspace']\n"
" ports: ['18080:8000']\n",
encoding="utf-8",
)
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": {
"docker_access": True,
# A deliberately tiny image. Pulling a ~165MB one
# OOM-kills the shared egress proxy, which buffers whole
# response bodies to scan them — a real defect, but a
# separate one from what this test covers. See the
# research note.
#
# quay.io deliberately, not Docker Hub: the egress proxy
# strips agent-set Authorization (so an agent cannot
# smuggle a credential out in a header), and Docker Hub
# requires a client-fetched, per-scope bearer token that
# the strip therefore removes. quay serves manifests with
# no Authorization at all, so a plain route is enough.
#
# token_patterns is still scoped off: registry traffic
# carries bearer JWTs by protocol and trips the generic
# rule. known_secrets stays on — it matches the bottle's
# own credentials, which is the detector that catches
# real exfil.
"egress": {"routes": [
{"host": "quay.io", "dlp": {
"outbound_detectors": ["known_secrets"],
}},
{"host": "cdn01.quay.io", "dlp": {
"outbound_detectors": ["known_secrets"],
}},
]},
}},
"agents": {"spike": {
"bottle": "dev", "skills": [], "prompt": "",
}},
})
spec = BottleSpec(
manifest=manifest,
agent_name="spike",
copy_cwd=True,
user_cwd=str(workspace),
)
backend = get_bottle_backend("macos-container")
plan = backend.prepare(spec, stage_dir=stage)
with backend.launch(plan) as bottle:
workdir = plan.workspace_plan.workdir
checks = (
"docker info >/dev/null && docker compose version && "
f"cd {workdir} && docker compose up -d --wait && "
"curl --fail --silent http://127.0.0.1:18080/ | "
"grep -q bottle-compose-ok"
)
result = bottle.exec(checks)
self.assertEqual(
0, result.returncode,
f"stdout={result.stdout!r}\nstderr={result.stderr!r}",
)
# podman's compat API reports rootlessness through its own
# native endpoint; the Docker-shaped SecurityOptions field does
# not carry it.
inspect = bottle.exec(
"podman info --format '{{.Host.Security.Rootless}}'"
)
self.assertIn("true", inspect.stdout.lower())
self.assertEqual(
0,
bottle.exec(
"test \"$(id -u)\" -ne 0"
).returncode,
"the podman service must not be running as bottle root",
)
self.assertNotEqual(
0,
bottle.exec("test -S /var/run/docker.sock").returncode,
"spike must never expose a host/rootful Docker socket",
)
# Asserted on an in-band marker, not on `docker run`'s exit
# code: podman 4.3.1's Docker-compat API swallows the
# container's status and returns 0 for everything, so an
# exit-code assertion here passes whether egress was blocked
# or wide open. That silent false pass is worse than no check
# at all, and it is exactly this check — the one proving a
# nested container cannot escape the egress path.
#
# busybox ships wget, so a failure here means egress was
# refused rather than the binary being absent.
direct = bottle.exec(
"docker run --rm --env HTTP_PROXY= --env HTTPS_PROXY= "
"--env http_proxy= --env https_proxy= "
"quay.io/prometheus/busybox sh -c "
"'wget -T 4 -qO- https://evil.example.com/ "
"&& echo ESCAPED || echo CONTAINED'"
)
self.assertIn(
"CONTAINED", direct.stdout,
"an inner container obtained direct, unproxied egress: "
f"stdout={direct.stdout!r} stderr={direct.stderr!r}",
)
self.assertNotIn("ESCAPED", direct.stdout)
finally:
shutil.rmtree(workspace, ignore_errors=True)
shutil.rmtree(stage, ignore_errors=True)
if __name__ == "__main__":
unittest.main()
@@ -22,7 +22,7 @@ from bot_bottle.backend.macos_container.launch import (
_agent_run_argv,
_identity_proxy_env,
)
from bot_bottle.backend.macos_container.rootless_docker import guest_env
from bot_bottle.backend.macos_container.rootless_podman import guest_env
from bot_bottle.manifest import ManifestIndex
_BOTTLE = "bot_bottle.backend.macos_container.bottle"
@@ -180,14 +180,14 @@ class TestIdentityTokenDelivery(unittest.TestCase):
self.assertNotIn("--env", argv)
class TestRootlessDockerEnvironment(unittest.TestCase):
class TestRootlessPodmanEnvironment(unittest.TestCase):
def test_disabled_bottle_gets_no_docker_environment(self) -> None:
self.assertEqual({}, guest_env(False))
def test_enabled_bottle_uses_only_guest_local_socket(self) -> None:
env = guest_env(True)
self.assertEqual(
"unix:///tmp/bot-bottle-docker-run/docker.sock", env["DOCKER_HOST"],
"unix:///tmp/bot-bottle-podman-run/podman.sock", env["DOCKER_HOST"],
)
self.assertNotIn("/var/run/docker.sock", " ".join(env.values()))
@@ -1,4 +1,4 @@
"""Unit coverage for the fail-closed macOS rootless-Docker spike."""
"""Unit coverage for the fail-closed macOS rootless-podman spike."""
from __future__ import annotations
@@ -9,7 +9,7 @@ from types import SimpleNamespace
from typing import cast
from unittest.mock import patch
from bot_bottle.backend.macos_container import rootless_docker
from bot_bottle.backend.macos_container import rootless_podman
from bot_bottle.backend.macos_container import launch as launch_mod
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
@@ -42,36 +42,48 @@ class _Plan:
agent_provision: _AgentProvision
class TestRootlessDockerStart(unittest.TestCase):
def test_bootstraps_then_waits_for_guest_local_daemon(self) -> None:
class TestRootlessPodmanStart(unittest.TestCase):
def test_bootstraps_then_waits_for_guest_local_service(self) -> None:
bottle = _Bottle([_result(0), _result(1), _result(0)])
with patch.object(rootless_docker.time, "sleep"):
rootless_docker.start(bottle)
self.assertIn("rootless-docker-init", bottle.commands[0])
with patch.object(rootless_podman.time, "sleep"):
rootless_podman.start(bottle)
self.assertIn("rootless-podman-init", bottle.commands[0])
self.assertEqual(2, bottle.commands.count("docker info >/dev/null 2>&1"))
def test_bootstrap_failure_is_fatal_without_privilege_fallback(self) -> None:
bottle = _Bottle([_result(1, stderr="newuidmap missing")])
with patch.object(rootless_docker, "die", side_effect=RuntimeError) as die:
bottle = _Bottle([_result(1, stderr="slirp4netns missing")])
with patch.object(rootless_podman, "die", side_effect=RuntimeError) as die:
with self.assertRaises(RuntimeError):
rootless_docker.start(bottle)
self.assertIn("newuidmap missing", die.call_args.args[0])
rootless_podman.start(bottle)
self.assertIn("slirp4netns missing", die.call_args.args[0])
self.assertEqual(1, len(bottle.commands))
def test_timeout_reports_guest_log(self) -> None:
bottle = _Bottle(
[_result(0)]
+ [_result(1) for _ in range(rootless_docker.READY_RETRIES)]
+ [_result(1) for _ in range(rootless_podman.READY_RETRIES)]
+ [_result(0, stdout="operation not permitted")]
)
with patch.object(rootless_docker.time, "sleep"), \
patch.object(rootless_docker, "die", side_effect=RuntimeError) as die:
with patch.object(rootless_podman.time, "sleep"), \
patch.object(rootless_podman, "die", side_effect=RuntimeError) as die:
with self.assertRaises(RuntimeError):
rootless_docker.start(bottle)
rootless_podman.start(bottle)
self.assertIn("operation not permitted", die.call_args.args[0])
class TestRootlessDockerImage(unittest.TestCase):
class TestRootlessPodmanDevices(unittest.TestCase):
def test_relaxes_only_the_two_blocked_device_nodes_as_root(self) -> None:
calls: list[tuple[str, list[str]]] = []
rootless_podman.prepare_guest_devices(
"bottle-1", lambda name, argv: calls.append((name, argv)),
)
self.assertEqual(1, len(calls))
name, argv = calls[0]
self.assertEqual("bottle-1", name)
self.assertIn("chmod 0666 /dev/fuse /dev/net/tun", argv[-1])
class TestRootlessPodmanImage(unittest.TestCase):
def test_layers_tooling_without_changing_base_image(self) -> None:
calls: list[tuple[str, str, str]] = []
@@ -79,16 +91,32 @@ class TestRootlessDockerImage(unittest.TestCase):
calls.append((image, context, dockerfile))
text = Path(dockerfile).read_text(encoding="utf-8")
self.assertIn("FROM agent:base", text)
self.assertIn("docker.io uidmap rootlesskit slirp4netns", text)
self.assertIn("sed -i '/^node:/d' /etc/subuid /etc/subgid", text)
self.assertEqual(1, text.count("node:100000:65536\\n' >> /etc/subuid"))
self.assertEqual(1, text.count("node:100000:65536\\n' >> /etc/subgid"))
self.assertIn("podman fuse-overlayfs slirp4netns uidmap", text)
self.assertIn("USER node", text)
self.assertTrue((Path(context) / "rootless-docker-init.sh").is_file())
self.assertTrue((Path(context) / "rootless-podman-init.sh").is_file())
image = rootless_docker.build_image("agent:base", build)
self.assertEqual("agent:base-rootless-docker", image)
self.assertEqual("agent:base-rootless-docker", calls[0][0])
image = rootless_podman.build_image("agent:base", build)
self.assertEqual("agent:base-rootless-podman", image)
self.assertEqual("agent:base-rootless-podman", calls[0][0])
def test_strips_subordinate_ranges_so_podman_avoids_newuidmap(self) -> None:
"""The single-UID fallback is the entire reason podman works here.
A subordinate range would send podman down the newuidmap path, which
cannot write a multi-range uid_map without CAP_SYS_ADMIN in an Apple
Container guest the failure that killed the rootless-Docker spike.
"""
seen: list[str] = []
def build(image: str, context: str, *, dockerfile: str) -> None:
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
rootless_podman.build_image("agent:base", build)
text = seen[0]
self.assertIn("sed -i '/^node:/d' /etc/subuid /etc/subgid", text)
self.assertNotIn("subuid", text.replace(
"sed -i '/^node:/d' /etc/subuid /etc/subgid", "",
))
def test_launch_builds_base_then_rootless_variant(self) -> None:
plan = cast(MacosContainerBottlePlan, cast(object, _Plan(
@@ -101,9 +129,9 @@ class TestRootlessDockerImage(unittest.TestCase):
with patch.object(launch_mod, "read_committed_image", return_value=None), \
patch.object(launch_mod.container_mod, "build_image") as build, \
patch.object(
launch_mod.rootless_docker,
launch_mod.rootless_podman,
"build_image",
return_value="agent:base-rootless-docker",
return_value="agent:base-rootless-podman",
) as build_rootless:
result = launch_mod._build_images(plan) # pylint: disable=protected-access
@@ -112,7 +140,7 @@ class TestRootlessDockerImage(unittest.TestCase):
dockerfile="/repo/Dockerfile",
)
build_rootless.assert_called_once_with("agent:base", build)
self.assertEqual("agent:base-rootless-docker", result.agent_provision.image)
self.assertEqual("agent:base-rootless-podman", result.agent_provision.image)
if __name__ == "__main__":