From 72e35a1343f0d3959138749348db91a159c85332 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Jul 2026 05:53:54 +0000 Subject: [PATCH] feat(macos): spike rootless Docker inside bottles Add an opt-in docker_access path that layers rootless Docker tooling onto the selected agent image, starts the daemon only after registration, and retains the existing outer network and capability boundary. Include fail-closed bootstrap checks plus a live-Mac Compose/security acceptance test.\n\nRefs #392. --- README.md | 8 ++ .../backend/macos_container/bottle_plan.py | 1 + bot_bottle/backend/macos_container/launch.py | 25 ++++- .../backend/macos_container/resolve_plan.py | 1 + .../macos_container/rootless-docker-init.sh | 57 ++++++++++ .../macos_container/rootless_docker.py | 93 ++++++++++++++++ bot_bottle/manifest_bottle.py | 11 ++ bot_bottle/manifest_extends.py | 8 ++ bot_bottle/manifest_schema.py | 5 +- .../test_macos_rootless_docker_spike.py | 103 ++++++++++++++++++ .../test_macos_container_launch_wiring.py | 14 +++ tests/unit/test_macos_rootless_docker.py | 74 +++++++++++++ tests/unit/test_manifest_bottle_merge.py | 6 + tests/unit/test_manifest_validation.py | 9 +- 14 files changed, 408 insertions(+), 7 deletions(-) create mode 100644 bot_bottle/backend/macos_container/rootless-docker-init.sh create mode 100644 bot_bottle/backend/macos_container/rootless_docker.py create mode 100644 tests/integration/test_macos_rootless_docker_spike.py create mode 100644 tests/unit/test_macos_rootless_docker.py diff --git a/README.md b/README.md index 557bd44..5944071 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,14 @@ 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 +> `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. + ### Firecracker on Linux On Linux, a KVM-capable host defaults to the Firecracker backend. It needs: diff --git a/bot_bottle/backend/macos_container/bottle_plan.py b/bot_bottle/backend/macos_container/bottle_plan.py index f93a092..d1ea4dd 100644 --- a/bot_bottle/backend/macos_container/bottle_plan.py +++ b/bot_bottle/backend/macos_container/bottle_plan.py @@ -20,6 +20,7 @@ class MacosContainerBottlePlan(BottlePlan): # bottle is registered. See launch.py's stamp for why it lives here and not # only in the exec-time proxy env. identity_token: str = "" + docker_access: bool = False @property def container_name(self) -> str: diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index c498130..7f17ee7 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -64,6 +64,7 @@ from .gateway_hosts import ( refresh_gateway_host, set_gateway_host, ) +from . import rootless_docker from .bottle_plan import MacosContainerBottlePlan from ...orchestrator.config_store import resolve_teardown_timeout from .consolidated_launch import ( @@ -171,6 +172,10 @@ def launch( # token above, so — unlike the run-time env — the plan CAN carry it. plan = dataclasses.replace(plan, identity_token=ctx.identity_token) + exec_env = { + **_identity_proxy_env(endpoint, ctx.identity_token), + **rootless_docker.guest_env(plan.docker_access), + } bottle = MacosContainerBottle( plan.container_name, teardown, @@ -184,10 +189,13 @@ def launch( ), terminal_color=plan.spec.color, agent_workdir=plan.workspace_plan.workdir, - exec_env=_identity_proxy_env(endpoint, ctx.identity_token), + exec_env=exec_env, ) bottle.prompt_path = provision(plan, bottle) + if plan.docker_access: + rootless_docker.start(bottle) + yield bottle finally: teardown() @@ -199,15 +207,22 @@ def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: committed = read_committed_image(plan.slug) if committed and container_mod.image_exists(committed): info(f"using committed image {committed!r}") - return dataclasses.replace( + plan = dataclasses.replace( plan, agent_provision=dataclasses.replace( plan.agent_provision, image=committed, ), ) - container_mod.build_image( - plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path, - ) + else: + container_mod.build_image( + plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path, + ) + if plan.docker_access: + image = rootless_docker.build_image(plan.image, container_mod.build_image) + plan = dataclasses.replace( + plan, + agent_provision=dataclasses.replace(plan.agent_provision, image=image), + ) return plan diff --git a/bot_bottle/backend/macos_container/resolve_plan.py b/bot_bottle/backend/macos_container/resolve_plan.py index 9a9eb28..6835775 100644 --- a/bot_bottle/backend/macos_container/resolve_plan.py +++ b/bot_bottle/backend/macos_container/resolve_plan.py @@ -44,4 +44,5 @@ def resolve_plan( egress_plan=egress_plan, supervise_plan=supervise_plan, agent_provision=agent_provision_plan, + docker_access=manifest.bottle.docker_access, ) diff --git a/bot_bottle/backend/macos_container/rootless-docker-init.sh b/bot_bottle/backend/macos_container/rootless-docker-init.sh new file mode 100644 index 0000000..e5cb607 --- /dev/null +++ b/bot_bottle/backend/macos_container/rootless-docker-init.sh @@ -0,0 +1,57 @@ +#!/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 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" + " && echo 'node:100000:65536' >> /etc/subuid \\\n" + " && echo 'node:100000:65536' >> /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/manifest_bottle.py b/bot_bottle/manifest_bottle.py index 8d47c6b..150032a 100644 --- a/bot_bottle/manifest_bottle.py +++ b/bot_bottle/manifest_bottle.py @@ -44,6 +44,9 @@ class ManifestBottle: # daemon that exposes egress MCP tools to the agent. Set # `supervise: false` to skip the gateway. supervise: bool = True + # Experimental guest-local container engine (issue #392). Backends must + # implement this without granting access to a host/shared daemon. + docker_access: bool = False @classmethod def from_dict(cls, name: str, raw: object) -> "ManifestBottle": @@ -123,7 +126,15 @@ class ManifestBottle: f"(was {type(supervise_raw).__name__})" ) + docker_access_raw = d.get("docker_access", False) + if not isinstance(docker_access_raw, bool): + raise ManifestError( + f"bottle '{name}' docker_access must be a boolean " + f"(was {type(docker_access_raw).__name__})" + ) + return cls( env=env, agent_provider=agent_provider, git=git, git_user=git_user, egress=egress, supervise=supervise_raw, + docker_access=docker_access_raw, ) diff --git a/bot_bottle/manifest_extends.py b/bot_bottle/manifest_extends.py index 28911e0..3417b2c 100644 --- a/bot_bottle/manifest_extends.py +++ b/bot_bottle/manifest_extends.py @@ -54,6 +54,7 @@ def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle git_user=merged_git_user, egress=merged_egress, supervise=override.supervise, + docker_access=override.docker_access, ) @@ -206,6 +207,7 @@ def _fold_two_bottles( git_user=merged_git_user, egress=merged_egress, supervise=later.supervise, + docker_access=later.docker_access, ), merged_repos_raw @@ -266,6 +268,11 @@ def _merge_bottles( merged_supervise = ( child.supervise if "supervise" in child_raw else parent.supervise ) + merged_docker_access = ( + child.docker_access + if "docker_access" in child_raw + else parent.docker_access + ) validate_egress_routes(name, merged_egress.routes) return ManifestBottle( @@ -275,6 +282,7 @@ def _merge_bottles( git_user=merged_git_user, egress=merged_egress, supervise=merged_supervise, + docker_access=merged_docker_access, ) diff --git a/bot_bottle/manifest_schema.py b/bot_bottle/manifest_schema.py index 3e292b2..e8f3cb4 100644 --- a/bot_bottle/manifest_schema.py +++ b/bot_bottle/manifest_schema.py @@ -16,7 +16,10 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$") # sets dies with a "did you mean" pointer: typos should not silently # ghost into an empty config. BOTTLE_KEYS = frozenset( - {"env", "extends", "agent_provider", "git-gate", "egress", "supervise"} + { + "env", "extends", "agent_provider", "git-gate", "egress", "supervise", + "docker_access", + } ) AGENT_KEYS_REQUIRED: frozenset[str] = frozenset() AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"}) diff --git a/tests/integration/test_macos_rootless_docker_spike.py b/tests/integration/test_macos_rootless_docker_spike.py new file mode 100644 index 0000000..bd872c4 --- /dev/null +++ b/tests/integration/test_macos_rootless_docker_spike.py @@ -0,0 +1,103 @@ +"""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/unit/test_macos_container_launch_wiring.py b/tests/unit/test_macos_container_launch_wiring.py index 1763e5d..e87bbfc 100644 --- a/tests/unit/test_macos_container_launch_wiring.py +++ b/tests/unit/test_macos_container_launch_wiring.py @@ -22,6 +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.manifest import ManifestIndex _BOTTLE = "bot_bottle.backend.macos_container.bottle" @@ -76,6 +77,7 @@ def _plan( ), agent_git_gate_url=agent_git_gate_url, agent_supervise_url=agent_supervise_url, + docker_access=False, )) @@ -178,6 +180,18 @@ class TestIdentityTokenDelivery(unittest.TestCase): self.assertNotIn("--env", argv) +class TestRootlessDockerEnvironment(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"], + ) + self.assertNotIn("/var/run/docker.sock", " ".join(env.values())) + + class TestPlanIdentityToken(unittest.TestCase): """git-gate's gitconfig extraHeader and the supervise MCP --header read `getattr(plan, "identity_token", "")` at provision time and both bypass the diff --git a/tests/unit/test_macos_rootless_docker.py b/tests/unit/test_macos_rootless_docker.py new file mode 100644 index 0000000..5b06b17 --- /dev/null +++ b/tests/unit/test_macos_rootless_docker.py @@ -0,0 +1,74 @@ +"""Unit coverage for the fail-closed macOS rootless-Docker spike.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from bot_bottle.backend.macos_container import rootless_docker + + +class _Bottle: + def __init__(self, results: list[SimpleNamespace]) -> None: + self.results = results + self.commands: list[str] = [] + + def exec(self, command: str) -> SimpleNamespace: + self.commands.append(command) + return self.results.pop(0) + + +def _result(returncode: int, *, stdout: str = "", stderr: str = "") -> SimpleNamespace: + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +class TestRootlessDockerStart(unittest.TestCase): + def test_bootstraps_then_waits_for_guest_local_daemon(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]) + 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: + with self.assertRaises(RuntimeError): + rootless_docker.start(bottle) + self.assertIn("newuidmap 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(0, stdout="operation not permitted")] + ) + with patch.object(rootless_docker.time, "sleep"), \ + patch.object(rootless_docker, "die", side_effect=RuntimeError) as die: + with self.assertRaises(RuntimeError): + rootless_docker.start(bottle) + self.assertIn("operation not permitted", die.call_args.args[0]) + + +class TestRootlessDockerImage(unittest.TestCase): + def test_layers_tooling_without_changing_base_image(self) -> None: + calls: list[tuple[str, str, str]] = [] + + def build(image: str, context: str, *, dockerfile: str) -> None: + 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("USER node", text) + self.assertTrue((Path(context) / "rootless-docker-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]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_manifest_bottle_merge.py b/tests/unit/test_manifest_bottle_merge.py index a0b3795..28e56db 100644 --- a/tests/unit/test_manifest_bottle_merge.py +++ b/tests/unit/test_manifest_bottle_merge.py @@ -56,6 +56,12 @@ class TestMergeBottlesRuntime(unittest.TestCase): result = merge_bottles_runtime([base, override]) self.assertFalse(result.supervise) + def test_docker_access_later_wins(self): + result = merge_bottles_runtime([ + _bottle(docker_access=False), _bottle(docker_access=True), + ]) + self.assertTrue(result.docker_access) + def test_three_bottles_merged_left_to_right(self): b1 = _bottle(env={"A": "1", "B": "1", "C": "1"}) b2 = _bottle(env={"B": "2", "C": "2"}) diff --git a/tests/unit/test_manifest_validation.py b/tests/unit/test_manifest_validation.py index a60ba14..04ec2a2 100644 --- a/tests/unit/test_manifest_validation.py +++ b/tests/unit/test_manifest_validation.py @@ -44,13 +44,20 @@ class TestBottleValidation(unittest.TestCase): with self.assertRaises(ManifestError): ManifestBottle.from_dict("b", {"supervise": "yes"}) + def test_docker_access_not_bool(self) -> None: + with self.assertRaises(ManifestError): + ManifestBottle.from_dict("b", {"docker_access": "yes"}) + def test_removed_runtime_field(self) -> None: with self.assertRaises(ManifestError): ManifestBottle.from_dict("b", {"runtime": "runsc"}) def test_valid_minimal(self) -> None: - b = ManifestBottle.from_dict("b", {"supervise": False, "env": {"X": "1"}}) + b = ManifestBottle.from_dict( + "b", {"supervise": False, "docker_access": True, "env": {"X": "1"}}, + ) self.assertFalse(b.supervise) + self.assertTrue(b.docker_access) self.assertEqual({"X": "1"}, dict(b.env))