"""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()