feat(macos): spike rootless Docker inside bottles
test / integration-docker (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / unit (pull_request) Successful in 37s
test / stage-firecracker-inputs (pull_request) Successful in 2s
lint / lint (push) Successful in 2m44s
test / build-infra (pull_request) Successful in 3m38s
test / integration-firecracker (pull_request) Successful in 2m7s
test / coverage (pull_request) Failing after 1m59s
test / publish-infra (pull_request) Has been skipped
test / integration-docker (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 11s
test / unit (pull_request) Successful in 37s
test / stage-firecracker-inputs (pull_request) Successful in 2s
lint / lint (push) Successful in 2m44s
test / build-infra (pull_request) Successful in 3m38s
test / integration-firecracker (pull_request) Successful in 2m7s
test / coverage (pull_request) Failing after 1m59s
test / publish-infra (pull_request) Has been skipped
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 </dev/null &
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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"
|
||||
" && 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 '<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"]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user