diff --git a/README.md b/README.md index 5944071..8488716 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,21 @@ On compatible macOS hosts, the default backend requires Apple's `container` CLI Use `BOT_BOTTLE_BACKEND=docker ./cli.py start ` 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 diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index 7f17ee7..258d23e 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -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), diff --git a/bot_bottle/backend/macos_container/rootless-docker-init.sh b/bot_bottle/backend/macos_container/rootless-docker-init.sh deleted file mode 100644 index e5cb607..0000000 --- a/bot_bottle/backend/macos_container/rootless-docker-init.sh +++ /dev/null @@ -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 &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 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 ''}") - - 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 '').strip()}" - ) - - -__all__ = ["build_image", "guest_env", "start"] diff --git a/bot_bottle/backend/macos_container/rootless_podman.py b/bot_bottle/backend/macos_container/rootless_podman.py new file mode 100644 index 0000000..178eebe --- /dev/null +++ b/bot_bottle/backend/macos_container/rootless_podman.py @@ -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 ''}") + + 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 '').strip()}" + ) + + +__all__ = ["build_image", "guest_env", "prepare_guest_devices", "start"] diff --git a/tests/integration/test_macos_rootless_docker_spike.py b/tests/integration/test_macos_rootless_docker_spike.py deleted file mode 100644 index bd872c4..0000000 --- a/tests/integration/test_macos_rootless_docker_spike.py +++ /dev/null @@ -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() diff --git a/tests/integration/test_macos_rootless_podman_spike.py b/tests/integration/test_macos_rootless_podman_spike.py new file mode 100644 index 0000000..37dfc0f --- /dev/null +++ b/tests/integration/test_macos_rootless_podman_spike.py @@ -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() diff --git a/tests/unit/test_macos_container_launch_wiring.py b/tests/unit/test_macos_container_launch_wiring.py index e87bbfc..fbe12c9 100644 --- a/tests/unit/test_macos_container_launch_wiring.py +++ b/tests/unit/test_macos_container_launch_wiring.py @@ -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())) diff --git a/tests/unit/test_macos_rootless_docker.py b/tests/unit/test_macos_rootless_podman.py similarity index 53% rename from tests/unit/test_macos_rootless_docker.py rename to tests/unit/test_macos_rootless_podman.py index ddd611d..4d24884 100644 --- a/tests/unit/test_macos_rootless_docker.py +++ b/tests/unit/test_macos_rootless_podman.py @@ -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__":