Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fea44067f | |||
| bf8ff91b31 | |||
| 315ed04979 | |||
| 9a04ab262b | |||
| cc094765fd | |||
| 0ba25352b9 | |||
| dba48706de | |||
| 854a8956ad | |||
| 7a9628fc03 | |||
| ca8b2a9f2c | |||
| 96f5be48a6 | |||
| 82cf9bab5a | |||
| 3c92e79775 | |||
| 220620bfcc | |||
| b0f012b8e6 | |||
| fa9fed4194 | |||
| d0b595828f | |||
| 182a28d724 | |||
| cfb2284b99 | |||
| 2cd06814e6 | |||
| 8fed02fd1e | |||
| a7b2befc06 | |||
| 6fdf090469 | |||
| d32e9cc3c3 |
+24
-11
@@ -1,4 +1,5 @@
|
|||||||
# Run the project's test suite on every PR push and on push to main.
|
# Run the project's test suite when package or runtime inputs change on a PR
|
||||||
|
# or on push to main.
|
||||||
#
|
#
|
||||||
# The suite uses stdlib `unittest` discovery — no external Python
|
# The suite uses stdlib `unittest` discovery — no external Python
|
||||||
# dependencies are required to execute it. Tests are split by directory:
|
# dependencies are required to execute it. Tests are split by directory:
|
||||||
@@ -23,22 +24,34 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- 'bot_bottle/**'
|
||||||
- '.gitea/workflows/**.yml'
|
- 'tests/**/*.py'
|
||||||
- 'scripts/**'
|
- 'cli.py'
|
||||||
- 'README.md'
|
- 'scripts/coverage.sh'
|
||||||
# Dockerfiles and pyproject.toml are baked into the infra rootfs; a
|
- 'scripts/critical-modules.txt'
|
||||||
# change here alters what the integration/coverage jobs build locally.
|
- 'scripts/diff_coverage.py'
|
||||||
|
- 'scripts/tracker_policy.py'
|
||||||
|
- 'scripts/firecracker-netpool.sh'
|
||||||
- 'Dockerfile*'
|
- 'Dockerfile*'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
|
- 'requirements-dev.txt'
|
||||||
|
- '.coveragerc'
|
||||||
|
- '.dockerignore'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- '**.py'
|
- 'bot_bottle/**'
|
||||||
- '.gitea/workflows/**.yml'
|
- 'tests/**/*.py'
|
||||||
- 'scripts/**'
|
- 'cli.py'
|
||||||
- 'README.md'
|
- 'scripts/coverage.sh'
|
||||||
|
- 'scripts/critical-modules.txt'
|
||||||
|
- 'scripts/diff_coverage.py'
|
||||||
|
- 'scripts/tracker_policy.py'
|
||||||
|
- 'scripts/firecracker-netpool.sh'
|
||||||
- 'Dockerfile*'
|
- 'Dockerfile*'
|
||||||
- 'pyproject.toml'
|
- 'pyproject.toml'
|
||||||
|
- 'requirements-dev.txt'
|
||||||
|
- '.coveragerc'
|
||||||
|
- '.dockerignore'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.BADGE_PUSH_TOKEN }}
|
||||||
|
|
||||||
# No actions/setup-python: the runner image ships Python 3.12 and older
|
# No actions/setup-python: the runner image ships Python 3.12 and older
|
||||||
# act_runner engines mishandle setup-python's PATH. Install into the
|
# act_runner engines mishandle setup-python's PATH. Install into the
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://coverage.readthedocs.io/)
|
[](https://coverage.readthedocs.io/)
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
[](https://gitea.dideric.is/didericis/bot-bottle/src/branch/main/docs/decisions/0004-coverage-policy.md)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
@@ -75,6 +75,88 @@ 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.
|
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.
|
||||||
|
|
||||||
|
### Containers inside a bottle
|
||||||
|
|
||||||
|
A bottle may set `nested_containers: true`. On the macOS backend this starts a
|
||||||
|
guest-local, rootless **podman** service after the bottle is registered and
|
||||||
|
exposes its Docker-compatible API socket, so the agent still runs `docker` and
|
||||||
|
`docker compose`. Nothing is mounted from the host: Docker Desktop's socket
|
||||||
|
stays out of the bottle and the guest gains no outer VM capabilities. Backends
|
||||||
|
that cannot do this (`docker`, `firecracker`) reject the flag rather than
|
||||||
|
silently ignore it.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Pulling images goes through the bottle's egress proxy like every other
|
||||||
|
request, so each registry needs a route — **and so does the CDN it redirects
|
||||||
|
layer blobs to**, which is a different host. Without the CDN route the pull
|
||||||
|
authenticates, fetches the manifest, then 403s partway through.
|
||||||
|
|
||||||
|
Docker Hub and GHCR additionally need `preserve_auth: true`: their token dance
|
||||||
|
uses a client-fetched per-scope bearer token that the proxy would otherwise
|
||||||
|
strip. Turn DLP off on every registry and CDN route — the bodies are
|
||||||
|
compressed layer blobs that no detector can read, and buffering them is what
|
||||||
|
triggers the shared-proxy OOM in #455.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nested_containers: true
|
||||||
|
egress:
|
||||||
|
routes:
|
||||||
|
# Docker Hub: registry, token endpoint, blob CDN.
|
||||||
|
- host: registry-1.docker.io
|
||||||
|
preserve_auth: true
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
- host: auth.docker.io
|
||||||
|
preserve_auth: true
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
- host: production.cloudfront.docker.com
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
# GHCR: registry + blob CDN.
|
||||||
|
- host: ghcr.io
|
||||||
|
preserve_auth: true
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
- host: pkg-containers.githubusercontent.com
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
# quay.io: registry + blob CDNs. No preserve_auth needed for public pulls.
|
||||||
|
- host: quay.io
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
- host: cdn01.quay.io
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
- host: cdn02.quay.io
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
- host: cdn03.quay.io
|
||||||
|
dlp: { outbound_detectors: false, inbound_detectors: false }
|
||||||
|
```
|
||||||
|
|
||||||
|
`mcr.microsoft.com` and `registry.k8s.io` follow the same shape and also
|
||||||
|
redirect blobs elsewhere (`*.data.mcr.microsoft.com` and
|
||||||
|
`us-*-docker.pkg.dev` respectively); route whichever host the 403 names.
|
||||||
|
|
||||||
|
Inside a nested container the same allowlist applies: an allowlisted host
|
||||||
|
returns 200 and anything else gets a 403 straight from the proxy. The
|
||||||
|
gateway's CA bundle and proxy settings are wired in automatically, so
|
||||||
|
`docker run … curl https://…` works with no extra flags — no `--add-host`,
|
||||||
|
`-e`, or `-v`.
|
||||||
|
|
||||||
|
Two things worth knowing when testing that:
|
||||||
|
|
||||||
|
- Public DNS inside a nested container fails **by design**. Everything
|
||||||
|
egresses through the proxy, so `nslookup` failing is expected and is not
|
||||||
|
evidence of a problem.
|
||||||
|
- Alpine's BusyBox `wget` drops the connection after the proxy's TLS
|
||||||
|
interception and reports `error getting response`, even though the proxy
|
||||||
|
logs the decrypted request and returns a response. Use `curl` to test
|
||||||
|
egress; BusyBox `wget` will lie to you.
|
||||||
|
|
||||||
### Firecracker on Linux
|
### Firecracker on Linux
|
||||||
|
|
||||||
On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
|
On Linux, a KVM-capable host defaults to the Firecracker backend. It needs:
|
||||||
|
|||||||
@@ -302,6 +302,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
# Whether this backend can run a container engine *inside* the bottle.
|
||||||
|
# Backends that cannot must reject `nested_containers: true` rather than
|
||||||
|
# reach for a host daemon socket (issue #392).
|
||||||
|
supports_nested_containers: bool = False
|
||||||
|
|
||||||
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
|
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
|
||||||
"""Template method: run cross-backend host-side validation, then
|
"""Template method: run cross-backend host-side validation, then
|
||||||
delegate to the subclass's `_resolve_plan` for the
|
delegate to the subclass's `_resolve_plan` for the
|
||||||
@@ -315,12 +320,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
prepare_egress,
|
prepare_egress,
|
||||||
prepare_git_gate,
|
prepare_git_gate,
|
||||||
prepare_supervise,
|
prepare_supervise,
|
||||||
|
reject_nested_containers,
|
||||||
resolve_manifest_dockerfile,
|
resolve_manifest_dockerfile,
|
||||||
write_launch_metadata,
|
write_launch_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
manifest = self._validate(spec)
|
manifest = self._validate(spec)
|
||||||
|
|
||||||
|
if not self.supports_nested_containers:
|
||||||
|
reject_nested_containers(self.name, manifest)
|
||||||
|
|
||||||
self._preflight()
|
self._preflight()
|
||||||
|
|
||||||
from ..git_gate_host_key import preflight_host_keys
|
from ..git_gate_host_key import preflight_host_keys
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
"""Cleanup for the Firecracker backend.
|
"""Cleanup for the Firecracker backend.
|
||||||
|
|
||||||
Orphans are: firecracker VMM processes whose config lives under our run
|
Reaps *orphans* only — resources with no live VM behind them:
|
||||||
dir, and the per-bottle run dirs. TAP slots free themselves (the flock
|
|
||||||
drops when the launcher exits), so there is nothing to reclaim there.
|
* orphan run dirs: a per-bottle run dir (holding the ~1G rootfs.ext4)
|
||||||
|
whose firecracker process has exited. These leak when a launch is
|
||||||
|
hard-killed before its teardown runs (host OOM/crash, a cancelled CI
|
||||||
|
job, `kill -9`); the clean-exit path already removes its own dir in
|
||||||
|
launch.py.
|
||||||
|
* orphan VM pids: a firecracker process whose run dir is already gone
|
||||||
|
— a VMM left lingering after its dir was removed.
|
||||||
|
|
||||||
|
A run dir with a *live* firecracker process is a running bottle and is
|
||||||
|
left strictly alone: it is neither killed nor removed. (The backend's
|
||||||
|
`enumerate_active` registry is still a stub — #354 — so a live process
|
||||||
|
is the only reliable "this bottle is in use" signal we have. Once the
|
||||||
|
registry lands, registry-orphaned-but-running VMs can be reaped too.)
|
||||||
|
|
||||||
|
TAP slots free themselves (the flock drops when the launcher exits), so
|
||||||
|
there is nothing to reclaim there.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,38 +37,73 @@ def _run_root() -> Path:
|
|||||||
return util.cache_dir() / "run"
|
return util.cache_dir() / "run"
|
||||||
|
|
||||||
|
|
||||||
def _orphan_vm_pids() -> list[int]:
|
def _run_dir_of(cmd: str, run_root: Path) -> Path | None:
|
||||||
"""firecracker processes whose --config-file is under our run dir."""
|
"""The bottle run dir a firecracker cmdline belongs to, or None.
|
||||||
run_root = str(_run_root())
|
|
||||||
|
A bottle VM is launched with `--config-file <run_root>/<slug>/config.json`,
|
||||||
|
so the run dir is the config file's parent when it sits directly under
|
||||||
|
the run root. Anything else (a builder VM, the infra VM elsewhere) is
|
||||||
|
not ours to reap here.
|
||||||
|
"""
|
||||||
|
toks = cmd.split()
|
||||||
|
for i, tok in enumerate(toks):
|
||||||
|
if tok == "--config-file" and i + 1 < len(toks):
|
||||||
|
parent = Path(toks[i + 1]).parent
|
||||||
|
if parent.parent == run_root:
|
||||||
|
return parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_processes(run_root: Path) -> tuple[set[str], list[int]]:
|
||||||
|
"""Inspect running firecracker VMs under ``run_root``.
|
||||||
|
|
||||||
|
Returns ``(live_run_dirs, orphan_pids)``:
|
||||||
|
* ``live_run_dirs`` — run dirs backed by a running VM (never reaped);
|
||||||
|
* ``orphan_pids`` — firecracker pids whose run dir no longer exists
|
||||||
|
(a lingering VMM to kill).
|
||||||
|
"""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pgrep", "-a", "firecracker"],
|
["pgrep", "-a", "firecracker"],
|
||||||
capture_output=True, text=True, check=False,
|
capture_output=True, text=True, check=False,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return []
|
return set(), []
|
||||||
pids: list[int] = []
|
live: set[str] = set()
|
||||||
|
orphan_pids: list[int] = []
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
parts = line.split(None, 1)
|
parts = line.split(None, 1)
|
||||||
if len(parts) != 2 or run_root not in parts[1]:
|
if len(parts) != 2:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
pids.append(int(parts[0]))
|
pid = int(parts[0])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
return pids
|
run_dir = _run_dir_of(parts[1], run_root)
|
||||||
|
if run_dir is None:
|
||||||
|
continue
|
||||||
|
if run_dir.is_dir():
|
||||||
|
live.add(str(run_dir))
|
||||||
|
else:
|
||||||
|
orphan_pids.append(pid)
|
||||||
|
return live, orphan_pids
|
||||||
|
|
||||||
|
|
||||||
def _run_dirs() -> list[str]:
|
def _orphan_run_dirs(run_root: Path, live: set[str]) -> list[str]:
|
||||||
run_root = _run_root()
|
"""Run dirs with no live VM behind them — the leaked ones to remove."""
|
||||||
if not run_root.is_dir():
|
if not run_root.is_dir():
|
||||||
return []
|
return []
|
||||||
return sorted(str(p) for p in run_root.iterdir() if p.is_dir())
|
return sorted(
|
||||||
|
str(p) for p in run_root.iterdir()
|
||||||
|
if p.is_dir() and str(p) not in live
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
def prepare_cleanup() -> FirecrackerBottleCleanupPlan:
|
||||||
|
run_root = _run_root()
|
||||||
|
live, orphan_pids = _scan_processes(run_root)
|
||||||
return FirecrackerBottleCleanupPlan(
|
return FirecrackerBottleCleanupPlan(
|
||||||
vm_pids=tuple(_orphan_vm_pids()),
|
vm_pids=tuple(orphan_pids),
|
||||||
run_dirs=tuple(_run_dirs()),
|
run_dirs=tuple(_orphan_run_dirs(run_root, live)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
@@ -167,6 +168,10 @@ def launch(
|
|||||||
# Step 6: build the per-bottle rootfs + SSH key, then boot.
|
# Step 6: build the per-bottle rootfs + SSH key, then boot.
|
||||||
run_dir = util.cache_dir() / "run" / plan.slug
|
run_dir = util.cache_dir() / "run" / plan.slug
|
||||||
run_dir.mkdir(parents=True, exist_ok=True)
|
run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Remove the run dir on teardown so the per-bottle rootfs.ext4 (~1G)
|
||||||
|
# doesn't leak. Registered before vm.terminate below so it runs *after*
|
||||||
|
# it (ExitStack is LIFO): the VM is gone before we rm its rootfs.
|
||||||
|
stack.callback(lambda: shutil.rmtree(run_dir, ignore_errors=True))
|
||||||
rootfs = run_dir / "rootfs.ext4"
|
rootfs = run_dir / "rootfs.ext4"
|
||||||
util.build_rootfs_ext4(agent_base, rootfs)
|
util.build_rootfs_ext4(agent_base, rootfs)
|
||||||
private_key, pubkey = util.generate_keypair(run_dir)
|
private_key, pubkey = util.generate_keypair(run_dir)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class MacosContainerBottleBackend(
|
|||||||
`--backend=macos-container`."""
|
`--backend=macos-container`."""
|
||||||
|
|
||||||
name = "macos-container"
|
name = "macos-container"
|
||||||
|
supports_nested_containers = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_available(cls) -> bool:
|
def is_available(cls) -> bool:
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class MacosContainerBottlePlan(BottlePlan):
|
|||||||
# bottle is registered. See launch.py's stamp for why it lives here and not
|
# bottle is registered. See launch.py's stamp for why it lives here and not
|
||||||
# only in the exec-time proxy env.
|
# only in the exec-time proxy env.
|
||||||
identity_token: str = ""
|
identity_token: str = ""
|
||||||
|
# Guest-local container engine (issue #392). Gates the derived image, the
|
||||||
|
# device-mode relaxation, and the resident podman service.
|
||||||
|
nested_containers: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def container_name(self) -> str:
|
def container_name(self) -> str:
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from .gateway_hosts import (
|
|||||||
refresh_gateway_host,
|
refresh_gateway_host,
|
||||||
set_gateway_host,
|
set_gateway_host,
|
||||||
)
|
)
|
||||||
|
from . import nested_containers as nested_containers_mod
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
from .bottle_plan import MacosContainerBottlePlan
|
||||||
from ...orchestrator.config_store import resolve_teardown_timeout
|
from ...orchestrator.config_store import resolve_teardown_timeout
|
||||||
from .consolidated_launch import (
|
from .consolidated_launch import (
|
||||||
@@ -82,10 +83,14 @@ _AGENT_SLEEP_SECONDS = "2147483647"
|
|||||||
def build_or_load_images(plan: MacosContainerBottlePlan) -> BottleImages:
|
def build_or_load_images(plan: MacosContainerBottlePlan) -> BottleImages:
|
||||||
"""Resolve the agent image ref for this plan. The gateway's own image is
|
"""Resolve the agent image ref for this plan. The gateway's own image is
|
||||||
built by `ensure_gateway` — it belongs to the shared singleton."""
|
built by `ensure_gateway` — it belongs to the shared singleton."""
|
||||||
|
return BottleImages(agent=_layer_nested_containers(plan, _agent_image(plan)))
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_image(plan: MacosContainerBottlePlan) -> str:
|
||||||
committed = read_committed_image(plan.slug)
|
committed = read_committed_image(plan.slug)
|
||||||
if committed and container_mod.image_exists(committed):
|
if committed and container_mod.image_exists(committed):
|
||||||
info(f"using committed image {committed!r}")
|
info(f"using committed image {committed!r}")
|
||||||
return BottleImages(agent=committed)
|
return committed
|
||||||
if plan.spec.image_policy == "cached":
|
if plan.spec.image_policy == "cached":
|
||||||
if not container_mod.image_exists(plan.image):
|
if not container_mod.image_exists(plan.image):
|
||||||
die(
|
die(
|
||||||
@@ -93,9 +98,31 @@ def build_or_load_images(plan: MacosContainerBottlePlan) -> BottleImages:
|
|||||||
"run without --cached-images to build it"
|
"run without --cached-images to build it"
|
||||||
)
|
)
|
||||||
info(f"using cached agent image {plan.image!r}")
|
info(f"using cached agent image {plan.image!r}")
|
||||||
return BottleImages(agent=plan.image)
|
return plan.image
|
||||||
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
container_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||||
return BottleImages(agent=plan.image)
|
return plan.image
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_nested_containers(
|
||||||
|
plan: MacosContainerBottlePlan, agent_image: str,
|
||||||
|
) -> str:
|
||||||
|
"""Add the guest-local container tooling on top of the agent image.
|
||||||
|
|
||||||
|
A separate derived tag, not the provider Dockerfile, so bottles that never
|
||||||
|
ask for nested containers carry none of its weight.
|
||||||
|
"""
|
||||||
|
if not plan.nested_containers:
|
||||||
|
return agent_image
|
||||||
|
derived = f"{agent_image}{nested_containers_mod.IMAGE_SUFFIX}"
|
||||||
|
if plan.spec.image_policy == "cached":
|
||||||
|
if not container_mod.image_exists(derived):
|
||||||
|
die(
|
||||||
|
f"cached nested-container image {derived!r} not found; "
|
||||||
|
"run without --cached-images to build it"
|
||||||
|
)
|
||||||
|
info(f"using cached nested-container image {derived!r}")
|
||||||
|
return derived
|
||||||
|
return nested_containers_mod.build_image(agent_image, container_mod.build_image)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -196,6 +223,10 @@ def launch(
|
|||||||
# token above, so — unlike the run-time env — the plan CAN carry it.
|
# token above, so — unlike the run-time env — the plan CAN carry it.
|
||||||
plan = dataclasses.replace(plan, identity_token=ctx.identity_token)
|
plan = dataclasses.replace(plan, identity_token=ctx.identity_token)
|
||||||
|
|
||||||
|
exec_env = {
|
||||||
|
**_identity_proxy_env(endpoint, ctx.identity_token),
|
||||||
|
**nested_containers_mod.guest_env(plan.nested_containers),
|
||||||
|
}
|
||||||
bottle = MacosContainerBottle(
|
bottle = MacosContainerBottle(
|
||||||
plan.container_name,
|
plan.container_name,
|
||||||
teardown,
|
teardown,
|
||||||
@@ -209,10 +240,16 @@ def launch(
|
|||||||
),
|
),
|
||||||
terminal_color=plan.spec.color,
|
terminal_color=plan.spec.color,
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
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)
|
bottle.prompt_path = provision(plan, bottle)
|
||||||
|
|
||||||
|
if plan.nested_containers:
|
||||||
|
nested_containers_mod.prepare_guest_devices(
|
||||||
|
plan.container_name, container_mod.exec_container_as_root,
|
||||||
|
)
|
||||||
|
nested_containers_mod.start(bottle)
|
||||||
|
|
||||||
yield bottle
|
yield bottle
|
||||||
finally:
|
finally:
|
||||||
teardown()
|
teardown()
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
uid="$(id -u)"
|
||||||
|
if [ "$uid" -eq 0 ]; then
|
||||||
|
echo "refusing to run the guest container engine as root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Every piece of podman 5's networking stack is checked here, because each
|
||||||
|
# one fails at a different and misleading layer if it is absent: no pasta and
|
||||||
|
# nothing starts at all; no nft and netavark cannot build the bridge every
|
||||||
|
# compose file expects; no aardvark-dns and DNS inside nested containers fails
|
||||||
|
# while everything else looks healthy.
|
||||||
|
for command in podman docker fuse-overlayfs pasta nft slirp4netns; do
|
||||||
|
command -v "$command" >/dev/null 2>&1 || {
|
||||||
|
echo "missing nested-container 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
|
||||||
|
|
||||||
|
# Short by necessity, not by accident: conmon's attach socket lives under
|
||||||
|
# this directory and must fit in a 108-byte sun_path. See nested_containers.py.
|
||||||
|
# Must stay in step with AGENT_CA_BUNDLE in bot_bottle/backend/util.py; a unit
|
||||||
|
# test pins the two together.
|
||||||
|
CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
[ -r "$CA_BUNDLE" ] || {
|
||||||
|
echo "gateway CA bundle $CA_BUNDLE is missing or unreadable" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The proxy URL the agent inherits names `bot-bottle-gateway`, which resolves
|
||||||
|
# only through this bottle's /etc/hosts. A nested container gets its own hosts
|
||||||
|
# file, so it cannot resolve the name and dies at "Could not resolve proxy".
|
||||||
|
#
|
||||||
|
# podman's containers.conf `hosts_file` would fix that, except the
|
||||||
|
# Docker-compatible API ignores it — it only takes effect for native
|
||||||
|
# `podman run`, and the agent types `docker`. So the name is resolved *here*
|
||||||
|
# and the address, not the name, goes into the proxy URL the nested container
|
||||||
|
# receives. Verified on macOS 26 / podman 5.4.2: with the address in place,
|
||||||
|
# https://quay.io returns 200 and a non-allowlisted host still gets 403, so
|
||||||
|
# the egress boundary applies inside nested containers too.
|
||||||
|
GATEWAY_NAME="bot-bottle-gateway"
|
||||||
|
gateway_ip="$(
|
||||||
|
awk -v name="$GATEWAY_NAME" '$2 == name { print $1; exit }' /etc/hosts
|
||||||
|
)"
|
||||||
|
[ -n "$gateway_ip" ] || {
|
||||||
|
echo "no /etc/hosts entry for $GATEWAY_NAME; the gateway address is" >&2
|
||||||
|
echo "needed so nested containers can reach the egress proxy" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bbp}"
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# The rest of this config is what lets a nested container reach the network:
|
||||||
|
#
|
||||||
|
# hosts_file only takes effect for native `podman run` — the
|
||||||
|
# Docker-compatible API ignores it, and the agent types
|
||||||
|
# `docker`. Kept anyway because it costs nothing and makes
|
||||||
|
# podman-native use behave; the compat path is covered by the
|
||||||
|
# address-bearing proxy URL below.
|
||||||
|
# volumes/env the gateway TLS-intercepts, so a container that does not
|
||||||
|
# trust the bottle's CA bundle gets "unable to get local issuer
|
||||||
|
# certificate". Mounting the bundle read-only and pointing the
|
||||||
|
# usual env vars at it covers curl, wget, python, and node
|
||||||
|
# without distro-specific trust commands.
|
||||||
|
#
|
||||||
|
# The proxy URL carries the bottle's identity token. podman already forwards
|
||||||
|
# that same URL into every nested container from the agent's own environment,
|
||||||
|
# so writing it to a 0600 file inside this disposable VM hands it to nobody
|
||||||
|
# new. It is never echoed.
|
||||||
|
CA_BUNDLE="$CA_BUNDLE" GATEWAY_NAME="$GATEWAY_NAME" GATEWAY_IP="$gateway_ip" \
|
||||||
|
CONTAINERS_CONF="$config/containers.conf" python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ca = os.environ["CA_BUNDLE"]
|
||||||
|
name = os.environ["GATEWAY_NAME"]
|
||||||
|
ip = os.environ["GATEWAY_IP"]
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
f"SSL_CERT_FILE={ca}",
|
||||||
|
f"CURL_CA_BUNDLE={ca}",
|
||||||
|
f"REQUESTS_CA_BUNDLE={ca}",
|
||||||
|
f"NODE_EXTRA_CA_CERTS={ca}",
|
||||||
|
]
|
||||||
|
# The gateway name resolves only through the bottle's /etc/hosts, which a
|
||||||
|
# nested container does not inherit, so hand it the address instead.
|
||||||
|
for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||||
|
value = os.environ.get(var)
|
||||||
|
if value:
|
||||||
|
entries.append(f"{var}={value.replace(name, ip)}")
|
||||||
|
# NO_PROXY keeps the name: it is matched against what a client asks for, and
|
||||||
|
# code inside a nested container still says "bot-bottle-gateway".
|
||||||
|
for var in ("NO_PROXY", "no_proxy"):
|
||||||
|
value = os.environ.get(var)
|
||||||
|
if value:
|
||||||
|
entries.append(f"{var}={value}")
|
||||||
|
|
||||||
|
path = Path(os.environ["CONTAINERS_CONF"])
|
||||||
|
path.write_text("\n".join([
|
||||||
|
"[containers]",
|
||||||
|
'cgroups="disabled"',
|
||||||
|
# podman copies the host's proxy vars into every container by default,
|
||||||
|
# and that copy *wins* over the env below — putting the unresolvable
|
||||||
|
# gateway name back. Turn it off so the address-bearing URLs stand.
|
||||||
|
"http_proxy=false",
|
||||||
|
'hosts_file="/etc/hosts"',
|
||||||
|
f'volumes=["{ca}:{ca}:ro"]',
|
||||||
|
"env=[",
|
||||||
|
*[f' "{entry}",' for entry in entries],
|
||||||
|
"]",
|
||||||
|
"[engine]",
|
||||||
|
'cgroup_manager="cgroupfs"',
|
||||||
|
'events_logger="file"',
|
||||||
|
"",
|
||||||
|
]), encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# This file is also what the Docker CLI copies into every container it starts,
|
||||||
|
# and being client-side it beats anything the podman service does — it is why
|
||||||
|
# containers.conf `env`, `http_proxy=false`, and the service's own environment
|
||||||
|
# all failed to change what a nested container saw. The address goes in here
|
||||||
|
# for the same reason it goes everywhere else: `bot-bottle-gateway` resolves
|
||||||
|
# in the bottle, never inside a nested container.
|
||||||
|
GATEWAY_NAME="$GATEWAY_NAME" GATEWAY_IP="$gateway_ip" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
name = os.environ["GATEWAY_NAME"]
|
||||||
|
ip = os.environ["GATEWAY_IP"]
|
||||||
|
|
||||||
|
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy", "")
|
||||||
|
# NO_PROXY keeps the name: it is matched against what a client asks for, and
|
||||||
|
# code inside a nested container still says "bot-bottle-gateway".
|
||||||
|
no_proxy = os.environ.get("NO_PROXY") or os.environ.get("no_proxy", "")
|
||||||
|
config = {"proxies": {"default": {
|
||||||
|
"httpProxy": proxy.replace(name, ip),
|
||||||
|
"httpsProxy": proxy.replace(name, ip),
|
||||||
|
"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
|
||||||
|
|
||||||
|
# Belt to the ~/.docker/config.json braces above, which is what actually
|
||||||
|
# decides this for `docker run`. The service environment is what podman falls
|
||||||
|
# back to for anything the CLI does not stamp — its own registry pulls, and
|
||||||
|
# containers created through the API by something other than the Docker CLI.
|
||||||
|
# Cheap, and it keeps the address consistent across both paths.
|
||||||
|
#
|
||||||
|
# Assigned via parameter expansion, never echoed: these carry the bottle's
|
||||||
|
# identity token.
|
||||||
|
for var in HTTP_PROXY HTTPS_PROXY http_proxy https_proxy; do
|
||||||
|
eval "value=\${$var:-}"
|
||||||
|
[ -n "$value" ] || continue
|
||||||
|
eval "export $var=\"\${value%%$GATEWAY_NAME*}$gateway_ip\${value#*$GATEWAY_NAME}\""
|
||||||
|
done
|
||||||
|
|
||||||
|
log=/tmp/bot-bottle-nested-containers.log
|
||||||
|
nohup podman system service --time=0 \
|
||||||
|
"unix://$XDG_RUNTIME_DIR/podman.sock" \
|
||||||
|
>"$log" 2>&1 </dev/null &
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Guest-local container engine for Apple-container bottles (issue #392).
|
||||||
|
|
||||||
|
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/nested-containers-init"
|
||||||
|
# Deliberately cryptic and short. podman derives conmon's attach socket as
|
||||||
|
# `$XDG_RUNTIME_DIR/libpod/tmp/socket/<64-hex-id>/attach`, and a Unix socket
|
||||||
|
# path may not exceed 108 bytes (`sun_path`). The descriptive
|
||||||
|
# `/tmp/bot-bottle-podman-run` produced a 116-byte path — over the limit, so
|
||||||
|
# attach would have broken as soon as anything got far enough to attach. Do
|
||||||
|
# not lengthen this for readability; it buys 8 bytes of headroom.
|
||||||
|
_RUNTIME_DIR = "/tmp/bbp"
|
||||||
|
_SOCKET = f"{_RUNTIME_DIR}/podman.sock"
|
||||||
|
_LOG = "/tmp/bot-bottle-nested-containers.log"
|
||||||
|
IMAGE_SUFFIX = "-nested-containers"
|
||||||
|
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 the nested-container tooling onto an already-built agent image.
|
||||||
|
|
||||||
|
Podman and its networking stack live here rather than in the base agent
|
||||||
|
images so that bottles without the flag pay no image-size cost.
|
||||||
|
|
||||||
|
# TODO(#394): replace this hand-rolled Dockerfile with a docker-layer
|
||||||
|
# abstraction once that infrastructure exists.
|
||||||
|
"""
|
||||||
|
image = f"{base_image}{IMAGE_SUFFIX}"
|
||||||
|
init_script = Path(__file__).with_name("nested-containers-init.sh")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="bot-bottle-nested-containers.") as tmp:
|
||||||
|
context = Path(tmp)
|
||||||
|
shutil.copy2(init_script, context / "nested-containers-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"
|
||||||
|
# podman 5's networking stack, installed explicitly because
|
||||||
|
# --no-install-recommends omits it and each missing piece fails
|
||||||
|
# at a different, misleading layer:
|
||||||
|
# podman -> moved here from the base agent images so that
|
||||||
|
# bottles without nested_containers pay no cost
|
||||||
|
# passt -> `pasta`, the default rootless netns helper
|
||||||
|
# (podman 4 used slirp4netns); without it
|
||||||
|
# nothing starts: "could not find pasta"
|
||||||
|
# nftables -> `nft`, which netavark shells out to for the
|
||||||
|
# bridge network every compose file expects
|
||||||
|
# aardvark-dns -> name resolution *inside* nested containers;
|
||||||
|
# without it DNS fails while everything else
|
||||||
|
# looks healthy
|
||||||
|
# slirp4netns stays as the documented fallback for pasta.
|
||||||
|
" && apt-get install -y --no-install-recommends "
|
||||||
|
"aardvark-dns fuse-overlayfs netavark nftables passt podman "
|
||||||
|
"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 design exists
|
||||||
|
# to route around.
|
||||||
|
" && sed -i '/^node:/d' /etc/subuid /etc/subgid\n"
|
||||||
|
"COPY nested-containers-init.sh "
|
||||||
|
"/usr/local/libexec/bot-bottle/nested-containers-init\n"
|
||||||
|
"RUN chmod 0755 /usr/local/libexec/bot-bottle/nested-containers-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 guest-local container engine")
|
||||||
|
result = bottle.exec(shlex.quote(_INIT)) # type: ignore[attr-defined]
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout or "").strip()
|
||||||
|
die(f"nested-container 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("guest-local container engine is ready")
|
||||||
|
return
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
logs = bottle.exec( # type: ignore[attr-defined]
|
||||||
|
f"tail -n 80 {_LOG} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
die(
|
||||||
|
"guest-local container engine did not become ready without additional "
|
||||||
|
f"outer privileges:\n{(logs.stdout or logs.stderr or '<no log>').strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build_image", "guest_env", "prepare_guest_devices", "start"]
|
||||||
@@ -44,4 +44,5 @@ def resolve_plan(
|
|||||||
egress_plan=egress_plan,
|
egress_plan=egress_plan,
|
||||||
supervise_plan=supervise_plan,
|
supervise_plan=supervise_plan,
|
||||||
agent_provision=agent_provision_plan,
|
agent_provision=agent_provision_plan,
|
||||||
|
nested_containers=manifest.bottle.nested_containers,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from ..bottle_state import (
|
|||||||
)
|
)
|
||||||
from ..egress import Egress, EgressPlan
|
from ..egress import Egress, EgressPlan
|
||||||
from ..git_gate import GitGate, GitGatePlan
|
from ..git_gate import GitGate, GitGatePlan
|
||||||
|
from ..log import die
|
||||||
from ..manifest import Manifest, ManifestBottle
|
from ..manifest import Manifest, ManifestBottle
|
||||||
from ..supervise import Supervise, SupervisePlan
|
from ..supervise import Supervise, SupervisePlan
|
||||||
from . import BottleSpec
|
from . import BottleSpec
|
||||||
@@ -112,6 +113,22 @@ def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPla
|
|||||||
return replace(provision, guest_env=merged)
|
return replace(provision, guest_env=merged)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_nested_containers(backend: str, manifest: Manifest) -> None:
|
||||||
|
"""Fail loudly when a backend cannot honor `nested_containers: true`.
|
||||||
|
|
||||||
|
Silently ignoring it would hand the agent a bottle where `docker` is not
|
||||||
|
there — and the only sound alternatives on these backends (a host daemon
|
||||||
|
socket, a privileged container) are exactly what issue #392 rules out.
|
||||||
|
"""
|
||||||
|
if not manifest.bottle.nested_containers:
|
||||||
|
return
|
||||||
|
die(
|
||||||
|
f"nested_containers is not supported on the {backend} backend. "
|
||||||
|
"Only macos-container runs a guest-local container engine today; "
|
||||||
|
"mounting the host Docker socket is not an option bot-bottle offers."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
||||||
"""Resolve a manifest-supplied dockerfile path relative to user_cwd."""
|
"""Resolve a manifest-supplied dockerfile path relative to user_cwd."""
|
||||||
path = Path(os.path.expanduser(path_value))
|
path = Path(os.path.expanduser(path_value))
|
||||||
@@ -122,6 +139,7 @@ def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"merge_provision_env_vars",
|
"merge_provision_env_vars",
|
||||||
|
"reject_nested_containers",
|
||||||
"mint_slug",
|
"mint_slug",
|
||||||
"prepare_agent_state_dir",
|
"prepare_agent_state_dir",
|
||||||
"prepare_egress",
|
"prepare_egress",
|
||||||
|
|||||||
@@ -524,6 +524,8 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
|
|||||||
lines.append(f" scheme: {r.AuthScheme}")
|
lines.append(f" scheme: {r.AuthScheme}")
|
||||||
|
|
||||||
lines.append(f" supervise: {'true' if bottle.supervise else 'false'}")
|
lines.append(f" supervise: {'true' if bottle.supervise else 'false'}")
|
||||||
|
if bottle.nested_containers:
|
||||||
|
lines.append(" nested_containers: true")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ RUN apt-get update \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
podman \
|
|
||||||
ripgrep \
|
ripgrep \
|
||||||
iproute2 \
|
iproute2 \
|
||||||
dnsutils \
|
dnsutils \
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ RUN apt-get update \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
podman \
|
|
||||||
procps \
|
procps \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ RUN apt-get update \
|
|||||||
curl \
|
curl \
|
||||||
fd-find \
|
fd-find \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
podman \
|
|
||||||
ripgrep \
|
ripgrep \
|
||||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
|||||||
fields["matches"] = matches_data
|
fields["matches"] = matches_data
|
||||||
if r.git_fetch:
|
if r.git_fetch:
|
||||||
fields["git"] = {"fetch": True}
|
fields["git"] = {"fetch": True}
|
||||||
|
if r.preserve_auth:
|
||||||
|
fields["preserve_auth"] = True
|
||||||
if (
|
if (
|
||||||
r.outbound_detectors is not None
|
r.outbound_detectors is not None
|
||||||
or r.inbound_detectors is not None
|
or r.inbound_detectors is not None
|
||||||
@@ -335,6 +337,8 @@ def egress_render_routes(
|
|||||||
lines.append(" git:")
|
lines.append(" git:")
|
||||||
if git_dict.get("fetch") is True:
|
if git_dict.get("fetch") is True:
|
||||||
lines.append(" fetch: true")
|
lines.append(" fetch: true")
|
||||||
|
if f.get("preserve_auth") is True:
|
||||||
|
lines.append(" preserve_auth: true")
|
||||||
if "dlp" in f:
|
if "dlp" in f:
|
||||||
dlp_dict: dict[str, object] = f["dlp"] # type: ignore
|
dlp_dict: dict[str, object] = f["dlp"] # type: ignore
|
||||||
lines.append(" dlp:")
|
lines.append(" dlp:")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Bottle schema (frontmatter):
|
|||||||
egress: { routes: [ <egress-route>, ... ] }
|
egress: { routes: [ <egress-route>, ... ] }
|
||||||
# route keys: host, matches, auth, role, dlp
|
# route keys: host, matches, auth, role, dlp
|
||||||
supervise: <bool> # optional (default true)
|
supervise: <bool> # optional (default true)
|
||||||
|
nested_containers: <bool> # optional (default false)
|
||||||
|
|
||||||
Agent schema (frontmatter):
|
Agent schema (frontmatter):
|
||||||
bottle: <bottle-name> # required
|
bottle: <bottle-name> # required
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ class ManifestBottle:
|
|||||||
# daemon that exposes egress MCP tools to the agent. Set
|
# daemon that exposes egress MCP tools to the agent. Set
|
||||||
# `supervise: false` to skip the gateway.
|
# `supervise: false` to skip the gateway.
|
||||||
supervise: bool = True
|
supervise: bool = True
|
||||||
|
# Guest-local container engine (issue #392). Not a host-daemon grant:
|
||||||
|
# backends implement it inside the bottle or reject it. Gated because it
|
||||||
|
# costs image weight, a resident service, and relaxed guest device modes
|
||||||
|
# that the majority of bottles never need.
|
||||||
|
nested_containers: bool = False
|
||||||
|
# Source fields retained across extends/runtime composition. Boolean
|
||||||
|
# defaults otherwise erase the distinction between "omitted" and an
|
||||||
|
# explicitly declared value (especially False).
|
||||||
|
declared_fields: frozenset[str] = frozenset()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||||
@@ -123,7 +132,16 @@ class ManifestBottle:
|
|||||||
f"(was {type(supervise_raw).__name__})"
|
f"(was {type(supervise_raw).__name__})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
nested_raw = d.get("nested_containers", False)
|
||||||
|
if not isinstance(nested_raw, bool):
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' nested_containers must be a boolean "
|
||||||
|
f"(was {type(nested_raw).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
env=env, agent_provider=agent_provider, git=git,
|
env=env, agent_provider=agent_provider, git=git,
|
||||||
git_user=git_user, egress=egress, supervise=supervise_raw,
|
git_user=git_user, egress=egress, supervise=supervise_raw,
|
||||||
|
nested_containers=nested_raw,
|
||||||
|
declared_fields=frozenset(d),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ from .manifest_git import ManifestGitUser, parse_git_gate_config
|
|||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
|
|
||||||
|
|
||||||
|
def _overlay_declared_bool(
|
||||||
|
base: ManifestBottle,
|
||||||
|
override: ManifestBottle,
|
||||||
|
field: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Overlay a defaulted boolean only when override declared it."""
|
||||||
|
value = getattr(override if field in override.declared_fields else base, field)
|
||||||
|
assert isinstance(value, bool)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
||||||
"""Merge an ordered list of pre-resolved ManifestBottle objects.
|
"""Merge an ordered list of pre-resolved ManifestBottle objects.
|
||||||
|
|
||||||
@@ -15,7 +26,13 @@ def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
|||||||
the same field-merge rules as the file-based extends machinery:
|
the same field-merge rules as the file-based extends machinery:
|
||||||
env: dict merge, later wins; git_user: per-field overlay, later
|
env: dict merge, later wins; git_user: per-field overlay, later
|
||||||
wins on non-empty; git (repos): union by name, later wins; egress
|
wins on non-empty; git (repos): union by name, later wins; egress
|
||||||
routes: concatenate; agent_provider, supervise: later replaces.
|
routes: concatenate; agent_provider, supervise, nested_containers:
|
||||||
|
later replaces (presence-aware).
|
||||||
|
|
||||||
|
Defaulted booleans use presence-aware replacement: if the later bottle
|
||||||
|
was loaded from a source that explicitly declared the key, its value
|
||||||
|
wins (so an explicit `false` can override an earlier `true`). If the
|
||||||
|
later bottle never mentioned the key, the earlier value is preserved.
|
||||||
"""
|
"""
|
||||||
if not bottles:
|
if not bottles:
|
||||||
raise ValueError("merge_bottles_runtime requires at least one bottle")
|
raise ValueError("merge_bottles_runtime requires at least one bottle")
|
||||||
@@ -53,7 +70,11 @@ def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle
|
|||||||
git=merged_git,
|
git=merged_git,
|
||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
supervise=override.supervise,
|
supervise=_overlay_declared_bool(base, override, "supervise"),
|
||||||
|
nested_containers=_overlay_declared_bool(
|
||||||
|
base, override, "nested_containers"
|
||||||
|
),
|
||||||
|
declared_fields=base.declared_fields | override.declared_fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,7 +226,11 @@ def _fold_two_bottles(
|
|||||||
git=merged_git,
|
git=merged_git,
|
||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
supervise=later.supervise,
|
supervise=_overlay_declared_bool(earlier, later, "supervise"),
|
||||||
|
nested_containers=_overlay_declared_bool(
|
||||||
|
earlier, later, "nested_containers"
|
||||||
|
),
|
||||||
|
declared_fields=earlier.declared_fields | later.declared_fields,
|
||||||
), merged_repos_raw
|
), merged_repos_raw
|
||||||
|
|
||||||
|
|
||||||
@@ -263,8 +288,9 @@ def _merge_bottles(
|
|||||||
if "agent_provider" in child_raw
|
if "agent_provider" in child_raw
|
||||||
else parent.agent_provider
|
else parent.agent_provider
|
||||||
)
|
)
|
||||||
merged_supervise = (
|
merged_supervise = _overlay_declared_bool(parent, child, "supervise")
|
||||||
child.supervise if "supervise" in child_raw else parent.supervise
|
merged_nested_containers = _overlay_declared_bool(
|
||||||
|
parent, child, "nested_containers"
|
||||||
)
|
)
|
||||||
validate_egress_routes(name, merged_egress.routes)
|
validate_egress_routes(name, merged_egress.routes)
|
||||||
|
|
||||||
@@ -275,6 +301,8 @@ def _merge_bottles(
|
|||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
supervise=merged_supervise,
|
supervise=merged_supervise,
|
||||||
|
nested_containers=merged_nested_containers,
|
||||||
|
declared_fields=parent.declared_fields | child.declared_fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# sets dies with a "did you mean" pointer: typos should not silently
|
||||||
# ghost into an empty config.
|
# ghost into an empty config.
|
||||||
BOTTLE_KEYS = frozenset(
|
BOTTLE_KEYS = frozenset(
|
||||||
{"env", "extends", "agent_provider", "git-gate", "egress", "supervise"}
|
{
|
||||||
|
"env", "extends", "agent_provider", "git-gate", "egress",
|
||||||
|
"supervise", "nested_containers",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
||||||
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
|
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# PRD prd-new: Containers inside a bottle
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** Claude
|
||||||
|
- **Created:** 2026-07-21
|
||||||
|
- **Issue:** #392
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Let an agent run `docker` and `docker compose` *inside* its bottle by starting
|
||||||
|
a guest-local rootless podman service that exposes a Docker-compatible API
|
||||||
|
socket. Gated per bottle by `nested_containers: true`. No host daemon socket is
|
||||||
|
mounted and no capability is added to the guest, on any backend.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Agent tasks routinely involve containers: `docker compose up` to verify a
|
||||||
|
scaffolded stack, a throwaway database for a test run, building an image to
|
||||||
|
check a Dockerfile works. Bottles have no way to do any of that, so those tasks
|
||||||
|
either fail or get done outside the bottle — which defeats the point of the
|
||||||
|
bottle.
|
||||||
|
|
||||||
|
## Goals / success criteria
|
||||||
|
|
||||||
|
- A bottle with `nested_containers: true` on the `macos-container` backend can
|
||||||
|
run `docker compose up -d --wait` against a stock compose file, pulling from
|
||||||
|
a routed registry and serving from the workspace.
|
||||||
|
- The agent's habits do not change: `docker` and `docker compose`, not
|
||||||
|
`podman`.
|
||||||
|
- No host Docker socket is mounted, on any backend.
|
||||||
|
- No capability is added to the Apple Container guest.
|
||||||
|
- Backends without a guest-local engine reject the flag with a clear error
|
||||||
|
rather than ignoring it.
|
||||||
|
- Bottles that do not set the flag carry none of its cost.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Nested containers as an isolation layer. The single-UID mapping means `root`
|
||||||
|
inside a nested container is the agent user outside it. This is for build and
|
||||||
|
test workloads; the bottle stays the security boundary.
|
||||||
|
- The `docker` and `firecracker` backends. Both reject the flag for now.
|
||||||
|
- Reaching registries that are not routed through the bottle's egress proxy.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Why podman, not rootless Docker
|
||||||
|
|
||||||
|
Apple Container's capability bounding set omits `CAP_SYS_ADMIN`, which the
|
||||||
|
kernel requires in order to write a multi-range `uid_map` via `newuidmap`.
|
||||||
|
Rootless Docker has no path that avoids that write, so it cannot run in a
|
||||||
|
bottle without granting `CAP_SYS_ADMIN` — which is close to root in practical
|
||||||
|
terms and gives up most of what the bottle is for.
|
||||||
|
|
||||||
|
Podman does have a path: with **no** subordinate UID range configured it falls
|
||||||
|
back to a single-UID self-mapping, which an unprivileged process may write
|
||||||
|
itself. The image build therefore *removes* the agent user's `/etc/subuid` and
|
||||||
|
`/etc/subgid` entries rather than adding them — their presence is exactly what
|
||||||
|
would send podman down the `newuidmap` path. Full negative result in
|
||||||
|
[`docs/research/rootless-docker-in-apple-container-spike.md`](../research/rootless-docker-in-apple-container-spike.md).
|
||||||
|
|
||||||
|
### The flag, and its name
|
||||||
|
|
||||||
|
The flag is kept because it gates real costs, not because podman needs a
|
||||||
|
privilege grant: ~100MB of derived image, a resident service per bottle, and
|
||||||
|
relaxed modes on `/dev/fuse` and `/dev/net/tun` that the majority of bottles
|
||||||
|
should not get.
|
||||||
|
|
||||||
|
It is named `nested_containers`, not `docker_access`. It implies no Docker and
|
||||||
|
grants access to nothing on the host — naming it after Docker access would
|
||||||
|
describe the one thing the design refuses to do.
|
||||||
|
|
||||||
|
### Shape
|
||||||
|
|
||||||
|
- `bot_bottle/backend/macos_container/nested_containers.py` — derived-image
|
||||||
|
build, guest env, device preparation, service start/readiness.
|
||||||
|
- `bot_bottle/backend/macos_container/nested-containers-init.sh` — the
|
||||||
|
unprivileged bootstrap that runs inside the bottle. Fails closed on every
|
||||||
|
prerequisite it needs (podman, the storage/network helpers, writable device
|
||||||
|
nodes, an *empty* subordinate range) rather than degrading.
|
||||||
|
- The derived image `…-nested-containers` layers the Docker CLI, the compose
|
||||||
|
plugin, and podman 5's networking stack — `passt` (pasta), `netavark`,
|
||||||
|
`nftables`, `aardvark-dns` — plus `fuse-overlayfs`, `slirp4netns`, and
|
||||||
|
`uidmap` onto the agent image. Podman itself already ships in every built-in
|
||||||
|
image (#451), but with `--no-install-recommends`, so none of the networking
|
||||||
|
pieces arrive with it. Each absence fails at a different and misleading
|
||||||
|
layer: no pasta and nothing starts; no `nft` and containers are created but
|
||||||
|
never start; no aardvark-dns and DNS inside a container fails while
|
||||||
|
everything else looks healthy. Image pulls keep working throughout, which is
|
||||||
|
what makes these read as compat-API bugs.
|
||||||
|
- `BottleBackend.supports_nested_containers` — false by default, so a backend
|
||||||
|
that cannot honor the flag fails in the shared `prepare` template.
|
||||||
|
|
||||||
|
Guest configuration the single-UID mapping forces:
|
||||||
|
`ignore_chown_errors` (no second UID for layers to be chowned to),
|
||||||
|
`cgroups="disabled"` and `cgroup_manager="cgroupfs"` (no cgroup delegation
|
||||||
|
reaches the guest), `events_logger="file"` (no journald socket).
|
||||||
|
|
||||||
|
### Registry reach
|
||||||
|
|
||||||
|
Image pulls egress through the bottle's proxy like everything else, so each
|
||||||
|
registry needs a route — **and so does the CDN it redirects blobs to**, which
|
||||||
|
is a separate host: `production.cloudfront.docker.com` for Docker Hub,
|
||||||
|
`pkg-containers.githubusercontent.com` for GHCR, `cdn0*.quay.io` for quay.
|
||||||
|
Without those the pull authenticates, fetches the manifest, and then 403s
|
||||||
|
partway through. Docker Hub and GHCR need `preserve_auth: true` — their
|
||||||
|
token dance uses a client-fetched per-scope bearer token that the proxy would
|
||||||
|
otherwise strip. Layer pulls should also set `dlp.outbound_detectors: false`
|
||||||
|
and `dlp.inbound_detectors: false`: body scanning on a multi-hundred-MB layer
|
||||||
|
is what triggers #455, and a registry route's scanned bodies are compressed
|
||||||
|
layer blobs rather than anything a detector can read.
|
||||||
|
|
||||||
|
### Reaching the network from a nested container
|
||||||
|
|
||||||
|
Public DNS inside a nested container fails by design: everything egresses
|
||||||
|
through the proxy. What was actually broken was reaching the proxy at all, and
|
||||||
|
it took four attempts to fix because podman applies proxy settings at several
|
||||||
|
layers and the last writer wins:
|
||||||
|
|
||||||
|
| Layer | Applies to |
|
||||||
|
|---|---|
|
||||||
|
| `~/.docker/config.json` proxies block | **every container the Docker CLI starts** — client-side, beats everything below |
|
||||||
|
| service environment | podman's own pulls, non-CLI API clients |
|
||||||
|
| `containers.conf` `env` | native `podman run` |
|
||||||
|
| `containers.conf` `hosts_file`, `http_proxy` | native `podman run` only — the compat API ignores both |
|
||||||
|
|
||||||
|
The gateway is named `bot-bottle-gateway`, which resolves only through the
|
||||||
|
bottle's `/etc/hosts`; a nested container gets its own. Since no config key
|
||||||
|
reaches the compat path, the name is resolved in the bottle and the *address*
|
||||||
|
is substituted into the proxy URL at each layer above. `NO_PROXY` keeps the
|
||||||
|
name, which is matched against what a client asks for.
|
||||||
|
|
||||||
|
The gateway TLS-intercepts, so the CA bundle is mounted read-only and
|
||||||
|
`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `REQUESTS_CA_BUNDLE`, and
|
||||||
|
`NODE_EXTRA_CA_CERTS` point at it — no distro-specific trust commands.
|
||||||
|
|
||||||
|
## Verified on macOS 26 / Apple Container 1.0.0 / podman 5.4.2
|
||||||
|
|
||||||
|
With plain `docker`, no extra flags: image pulls from Docker Hub, GHCR, and
|
||||||
|
quay; `docker run` with correct exit-code propagation; `docker compose` up,
|
||||||
|
logs, down, and a published port curl'd from the bottle; `curl https://quay.io`
|
||||||
|
and `https://pypi.org/simple/` returning 200 from inside a nested container;
|
||||||
|
and `https://example.com` returning **403** — the egress allowlist applies to
|
||||||
|
nested containers, not just the bottle.
|
||||||
|
|
||||||
|
Alpine's BusyBox `wget` drops the connection after TLS interception and
|
||||||
|
reports "error getting response". The proxy logs the decrypted request, and
|
||||||
|
`curl` on the same host succeeds, so this is a BusyBox client limitation
|
||||||
|
rather than anything in the egress path.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether the `docker` and `firecracker` backends want an equivalent, or
|
||||||
|
whether guest-local containers stay macOS-only.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Egress proxy OOMs on large downloads
|
||||||
|
|
||||||
|
Found on 2026-07-21 while running the rootless-podman spike
|
||||||
|
(`docs/research/rootless-docker-in-apple-container-spike.md`). Recorded
|
||||||
|
rather than fixed — the fix is a security-relevant decision, not a
|
||||||
|
mechanical patch.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A single large HTTPS download through the gateway kills the egress
|
||||||
|
proxy. `mitmdump` buffers whole response bodies so the DLP detectors can
|
||||||
|
scan them, grows past the gateway container's memory limit, and is
|
||||||
|
OOM-killed by the cgroup. Nothing restarts it.
|
||||||
|
|
||||||
|
Two properties make this worse than a failed download:
|
||||||
|
|
||||||
|
- **The gateway is a per-host singleton.** Every bottle shares it, so
|
||||||
|
one bottle's download takes egress away from all of them.
|
||||||
|
- **There is no restart on death.** The gateway supervisor is
|
||||||
|
`while : ; do wait ; done`; a killed daemon stays dead until the infra
|
||||||
|
container is recreated.
|
||||||
|
|
||||||
|
So ordinary agent activity — pulling a container image, downloading a
|
||||||
|
model or dataset, fetching a large tarball — is a denial of service
|
||||||
|
against every other bottle on the host. No malice required, though it is
|
||||||
|
trivially reachable on purpose.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Triggered by `docker compose up` pulling `quay.io/fedora/python-312`
|
||||||
|
(two layers, ~82MB and ~83MB) inside a bottle. The pull itself
|
||||||
|
succeeded; the *next* request failed:
|
||||||
|
|
||||||
|
```
|
||||||
|
initializing source docker://quay.io/fedora/python-312:latest:
|
||||||
|
pinging container registry quay.io: Get "https://quay.io/v2/":
|
||||||
|
proxyconnect tcp: dial tcp 192.168.128.39:9099: connect: connection refused
|
||||||
|
```
|
||||||
|
|
||||||
|
From the gateway's `dmesg`:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0
|
||||||
|
oom-kill:constraint=CONSTRAINT_MEMCG,
|
||||||
|
oom_memcg=/container/bot-bottle-mac-infra,
|
||||||
|
task_memcg=/container/bot-bottle-mac-infra,task=mitmdump,pid=118
|
||||||
|
Memory cgroup out of memory: Killed process 118 (mitmdump)
|
||||||
|
total-vm:1391936kB, anon-rss:997768kB
|
||||||
|
```
|
||||||
|
|
||||||
|
~1GB RSS against a 1024MB container. Note the amplification: ~165MB of
|
||||||
|
layers produced ~1GB of resident memory, so the buffering is several
|
||||||
|
copies deep (encoded body, decoded body, and the text conversion the
|
||||||
|
regex detectors scan).
|
||||||
|
|
||||||
|
Afterwards the gateway container was still running and healthy-looking —
|
||||||
|
orchestrator, supervise, and git-http all alive — with no `mitmdump`
|
||||||
|
process at all, and it stayed that way until the container was
|
||||||
|
recreated. A liveness check on the container would not have caught this.
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1. Launch any bottle with an egress route to a host serving a large file.
|
||||||
|
2. Download >~150MB over HTTPS through the proxy.
|
||||||
|
3. `dmesg | grep -i oom` inside `bot-bottle-mac-infra`, and note that no
|
||||||
|
`mitmdump` process remains.
|
||||||
|
|
||||||
|
Beware a false negative when checking: truncating the process listing
|
||||||
|
(`cut -c1-45`) cuts before the binary name, because `mitmdump` runs as
|
||||||
|
`/usr/local/bin/python3.12 /usr/local/bin/mitmdump …`.
|
||||||
|
|
||||||
|
## Fix options, not yet chosen
|
||||||
|
|
||||||
|
1. **Restart dead daemons.** Smallest change and strictly an
|
||||||
|
improvement: an OOM then degrades one download instead of removing
|
||||||
|
egress for every bottle. Does not stop the OOM.
|
||||||
|
2. **Cap the scanned body size.** Above a threshold, stop buffering —
|
||||||
|
either skip the scan or stream it. This is the root-cause fix and a
|
||||||
|
security decision: a size threshold is exactly the hole an exfiltrator
|
||||||
|
would aim for, so "skip above N" trades a DoS for a covert channel.
|
||||||
|
Streaming with a bounded window keeps coverage, at more complexity.
|
||||||
|
3. **Raise the gateway's memory limit.** Moves the threshold; does not
|
||||||
|
remove it.
|
||||||
|
|
||||||
|
Worth noting that (1) and (2) are complementary — the restart gap is
|
||||||
|
worth closing regardless of how the memory behaviour is resolved.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Firecracker Image Remote Store
|
||||||
|
|
||||||
|
**Date:** 2026-07-22
|
||||||
|
**Context:** PR #459 (run-dir leak fix) surfaced that committed Firecracker snapshots
|
||||||
|
currently live only on the host machine. Once a host is wiped or a run-dir is
|
||||||
|
evicted, a user's preserved bottle is gone. This note investigates a secure remote
|
||||||
|
store so committed images survive host turnover and can be restored without the
|
||||||
|
user having to pre-flag which sessions to keep.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
Backblaze B2 + Cloudflare CDN is the cost-optimal choice for most deployments.
|
||||||
|
Cloudflare R2 is the simpler zero-config option at slightly higher storage cost.
|
||||||
|
Self-hosted MinIO is the right call for air-gapped or on-premises installs.
|
||||||
|
|
||||||
|
On the image-size front, zstd-compressing the committed tar before upload
|
||||||
|
produces roughly a 60–70% reduction with negligible impact on restore latency.
|
||||||
|
OverlayFS (for in-flight working rootfs, not for the committed artifact) cuts
|
||||||
|
per-instance disk use to ~10–50 MB per extra bottle sharing the same base.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Gets Stored
|
||||||
|
|
||||||
|
The Firecracker backend produces two artifact types:
|
||||||
|
|
||||||
|
| Artifact | Created by | Size | Lifetime |
|
||||||
|
|----------|-----------|------|---------|
|
||||||
|
| `rootfs/agent-<digest>/` (dir) | `image_builder.py` → `mke2fs -d` | ~1 GB as ext4 | Cached per Dockerfile hash; evictable |
|
||||||
|
| `committed/<slug>/rootfs.tar` | `FirecrackerFreezer._freeze` via SSH tar | 500 MB–1 GB | User-preserved; must survive host wipe |
|
||||||
|
|
||||||
|
The committed artifact is a tar of the guest's live filesystem streamed out over
|
||||||
|
SSH (`freezer.py:58–90`). At resume time `launch.py` calls `mke2fs -d` to
|
||||||
|
rebuild a fresh ext4 from this tar. The tar — not the ext4 — is what needs to be
|
||||||
|
pushed to remote storage and pulled back at restore time.
|
||||||
|
|
||||||
|
The base image cache (`rootfs/agent-<digest>/`) is derivable from the Dockerfile
|
||||||
|
and can be rebuilt on demand; it is lower priority for remote storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Candidates
|
||||||
|
|
||||||
|
### Object storage
|
||||||
|
|
||||||
|
| Provider | Storage | Egress | Notes |
|
||||||
|
|----------|---------|--------|-------|
|
||||||
|
| **Backblaze B2** | $0.006/GB | Free (via Cloudflare Bandwidth Alliance) | Cheapest storage; pairs with Cloudflare CDN to eliminate egress |
|
||||||
|
| **Cloudflare R2** | $0.015/GB | $0 always | Zero-config egress; no lifecycle transitions (limitation) |
|
||||||
|
| **Wasabi** | $0.0069/GB | Free (1:1 ratio) | 90-day minimum retention; good for archival; lifecycle evaluated daily |
|
||||||
|
| **AWS S3** | $0.023/GB | $0.09/GB | Richest lifecycle support; expensive at scale; avoid unless already in AWS |
|
||||||
|
| **MinIO** (self-hosted) | Host cost only | None | S3-compatible; best for private/on-prem deployments |
|
||||||
|
|
||||||
|
**B2 + Cloudflare CDN** is effectively $0.006/GB with zero egress — about 18×
|
||||||
|
cheaper than S3 for restore-heavy workloads. **R2** is the zero-config choice
|
||||||
|
($0 egress by default, no Bandwidth Alliance pairing needed) at a slightly
|
||||||
|
higher storage rate.
|
||||||
|
|
||||||
|
**Cloudflare R2's missing lifecycle support** is the main caveat: auto-eviction
|
||||||
|
rules (evict images older than N days) cannot currently be expressed natively in
|
||||||
|
R2. Wasabi and S3 both support declarative lifecycle policies.
|
||||||
|
|
||||||
|
### Retention policy recommendation
|
||||||
|
|
||||||
|
The comment proposes:
|
||||||
|
- Retain images for ~1 week by default
|
||||||
|
- Warn when approaching a capacity threshold
|
||||||
|
- Auto-evict oldest images once threshold is exceeded
|
||||||
|
|
||||||
|
This maps cleanly to an application-level policy (not a provider lifecycle rule),
|
||||||
|
which avoids the R2 limitation and works consistently across providers:
|
||||||
|
|
||||||
|
1. On `commit`: upload tar, record `(slug, size_bytes, uploaded_at)` in a local
|
||||||
|
or remote manifest file.
|
||||||
|
2. On startup / on `list`: scan the manifest, warn if total stored size exceeds
|
||||||
|
e.g. 80% of the configured threshold.
|
||||||
|
3. On eviction run (CLI or cron): delete objects older than `retention_days`
|
||||||
|
(default 7) that push total over `max_capacity`; oldest-first.
|
||||||
|
|
||||||
|
This keeps the policy logic in bot-bottle and the storage provider as a dumb
|
||||||
|
object store — no vendor-specific lifecycle API required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Size Reduction
|
||||||
|
|
||||||
|
### Current artifact sizes
|
||||||
|
|
||||||
|
A typical committed tar for a Claude Code agent image is 500 MB–1 GB uncompressed.
|
||||||
|
The per-run ext4 (copy of the base, written at `start`) adds another ~1 GB of
|
||||||
|
local disk. The leak fix in this PR addresses the ext4 copies; the remote store
|
||||||
|
addresses the committed tars.
|
||||||
|
|
||||||
|
### Compression
|
||||||
|
|
||||||
|
zstd compression of the committed tar before upload is the highest-leverage
|
||||||
|
single change:
|
||||||
|
|
||||||
|
| Codec | Typical size (1 GB rootfs) | Compress speed | Decompress speed |
|
||||||
|
|-------|---------------------------|---------------|-----------------|
|
||||||
|
| gzip | 350–430 MB | ~100 MB/s | ~500 MB/s |
|
||||||
|
| **zstd (default)** | **330–360 MB** | **~400 MB/s** | **~2 GB/s** |
|
||||||
|
| xz | 290–320 MB | ~20 MB/s | ~200 MB/s |
|
||||||
|
|
||||||
|
**zstd is the best trade-off**: 65–67% size reduction, near-instantaneous
|
||||||
|
decompression. The `tar` call in `freezer.py` could pipe through `zstd` before
|
||||||
|
writing to disk and to the remote; `resume` decompresses on the way back. A
|
||||||
|
`.tar.zst` suffix marks compressed artifacts so old tars remain restorable
|
||||||
|
without the codec.
|
||||||
|
|
||||||
|
### SquashFS for the base image cache
|
||||||
|
|
||||||
|
The `rootfs/agent-<digest>/` directory (the buildah-exported tree) is rebuilt by
|
||||||
|
`image_builder.py` and turned into per-run ext4 by `mke2fs -d`. Storing the
|
||||||
|
base as a SquashFS image instead of a flat directory tree would reduce it from
|
||||||
|
~1 GB to ~330–360 MB and make the cache remote-friendly. Firecracker does not
|
||||||
|
directly boot SquashFS, but the existing `mke2fs -d` path reads a directory tree
|
||||||
|
— a SquashFS mount could serve as the source. This is a larger change and lower
|
||||||
|
priority than tar compression.
|
||||||
|
|
||||||
|
### OverlayFS for per-run rootfs
|
||||||
|
|
||||||
|
Multiple simultaneous bottles sharing the same agent image today each get a full
|
||||||
|
`mke2fs -d` copy (~1 GB). OverlayFS (read-only base + writable sparse overlay)
|
||||||
|
would reduce this to ~10–50 MB per instance beyond the first:
|
||||||
|
|
||||||
|
- Mount the base image directory as read-only lower layer
|
||||||
|
- Attach a sparse ext4 or tmpfs writable layer per bottle
|
||||||
|
- Pass the merged overlay to Firecracker as the block device
|
||||||
|
|
||||||
|
E2B's public write-up on Firecracker + OverlayFS confirms this approach works
|
||||||
|
at scale. The `launch.py` changes would be non-trivial (device mapper or
|
||||||
|
`fuse-overlayfs` plumbing), so this is a follow-up rather than a prerequisite
|
||||||
|
for the remote store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
**Phase 1 — remote store with zstd (tight scope, actionable now)**
|
||||||
|
|
||||||
|
1. Add `--zstd` to the `tar` call in `FirecrackerFreezer._freeze`; name the
|
||||||
|
artifact `rootfs.tar.zst`. Keep uncompressed restore path for legacy tars.
|
||||||
|
2. Add a `bb firecracker upload <slug>` / `bb firecracker pull <slug>` pair that
|
||||||
|
pushes/fetches the compressed tar to the configured object store (S3-compatible
|
||||||
|
API, so B2, R2, MinIO, and Wasabi all work with the same client).
|
||||||
|
3. Store a `manifest.json` in the bucket (or a local mirror) tracking slug →
|
||||||
|
`{size, uploaded_at}`. Use it for threshold warnings and eviction.
|
||||||
|
4. Default retention: 7 days, configurable via `firecracker.image_retention_days`
|
||||||
|
in `~/.config/bot-bottle/config.toml` (or equivalent).
|
||||||
|
5. Warn at 80% of `max_capacity` (default e.g. 50 GB); evict oldest on commit
|
||||||
|
once at 100%.
|
||||||
|
|
||||||
|
**Storage recommendation:** Cloudflare R2 for hosted deployments (zero egress,
|
||||||
|
zero config), MinIO for private/on-premises.
|
||||||
|
|
||||||
|
**Phase 2 — base image cache compression**
|
||||||
|
|
||||||
|
Compress the `agent-<digest>` cache dir as a `.tar.zst` to save ~65% on repeated
|
||||||
|
image uploads. Low urgency since the base image is rebuildable.
|
||||||
|
|
||||||
|
**Phase 3 — OverlayFS per-run disk**
|
||||||
|
|
||||||
|
Replace the full per-run ext4 copy with an OverlayFS sparse layer. Largest disk
|
||||||
|
impact (~90–95% savings per concurrent bottle) but highest implementation
|
||||||
|
complexity. Track as a separate PRD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Does the host have a configured object-store credential path, or should the
|
||||||
|
remote store be an opt-in with an explicit `bb config set image-store.url ...`?
|
||||||
|
- Should `commit` automatically upload, or should upload be an explicit step to
|
||||||
|
avoid surprise egress?
|
||||||
|
- What is the acceptable cold-start latency for a restore from remote? A 330 MB
|
||||||
|
zstd tar at 100 Mbit/s takes ~26 s; at 1 Gbit/s, ~2.6 s. This bounds the
|
||||||
|
retention strategy (evict from local after successful upload vs keep local copy).
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
# Rootless Docker inside Apple Container bottles
|
||||||
|
|
||||||
|
Spike branch: `spike/rootless-docker-macos` (`a4d8461`)
|
||||||
|
|
||||||
|
**Outcome:** the podman recommendation below shipped as the
|
||||||
|
`nested_containers` bottle flag — see
|
||||||
|
[`docs/prds/prd-new-nested-containers.md`](../prds/prd-new-nested-containers.md).
|
||||||
|
The `docker_access` name used throughout the spike text was renamed on the
|
||||||
|
way in; it granted no access to anything on the host.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Negative result.** Rootless Docker cannot run inside an Apple
|
||||||
|
Container bottle without granting the bottle `CAP_SYS_ADMIN`. This is a
|
||||||
|
kernel constraint on writing multi-range `uid_map`, not a packaging gap
|
||||||
|
we can close with a better init script, a different base image, or more
|
||||||
|
careful `/etc/subuid` handling.
|
||||||
|
|
||||||
|
The spike was built on the premise — stated in
|
||||||
|
`bot_bottle/backend/macos_container/rootless_docker.py` — that it would
|
||||||
|
*"deliberately refuse to compensate for missing prerequisites with outer
|
||||||
|
capabilities, a privileged container, or a host Docker socket."* That
|
||||||
|
premise is exactly what the experiment falsified. The two ways forward
|
||||||
|
are to abandon the premise (add `CAP_SYS_ADMIN` to the bottle, and with
|
||||||
|
it most of the isolation the bottle exists to provide) or to abandon
|
||||||
|
rootless Docker.
|
||||||
|
|
||||||
|
Recommendation: abandon rootless Docker. Podman does not have this
|
||||||
|
problem — see [Podman is not blocked by
|
||||||
|
this](#podman-is-not-blocked-by-this) below.
|
||||||
|
|
||||||
|
## Local environment
|
||||||
|
|
||||||
|
Tested on 2026-07-21:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sw_vers
|
||||||
|
ProductName: macOS
|
||||||
|
ProductVersion: 26.5.1
|
||||||
|
BuildVersion: 25F80
|
||||||
|
|
||||||
|
$ container --version
|
||||||
|
container CLI version 1.0.0 (build: release, commit: ee848e3)
|
||||||
|
|
||||||
|
$ uname -a # inside the bottle
|
||||||
|
Linux ... 6.18.15 #1 SMP Tue Mar 17 01:36:53 UTC 2026 aarch64 GNU/Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## The failure
|
||||||
|
|
||||||
|
`tests/integration/test_macos_rootless_docker_spike.py` builds the
|
||||||
|
image, launches the bottle, and dies in `rootless_docker.start`:
|
||||||
|
|
||||||
|
```
|
||||||
|
+ exec rootlesskit --net=slirp4netns --mtu=65520 ... dockerd-rootless.sh
|
||||||
|
[rootlesskit:parent] error: failed to setup UID/GID map:
|
||||||
|
newuidmap 1100 [0 1000 1 1 100000 65536] failed:
|
||||||
|
newuidmap: write to uid_map failed: Operation not permitted
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why it fails
|
||||||
|
|
||||||
|
Every prerequisite you would normally suspect is present and correct in
|
||||||
|
the guest:
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `/usr/bin/newuidmap` | `-rwsr-xr-x root root` — setuid bit intact, survived the OCI export |
|
||||||
|
| `/` mount options | `rw,relatime` — **not** `nosuid` |
|
||||||
|
| `NoNewPrivs` | `0` |
|
||||||
|
| `Seccomp` | `0`, no filters |
|
||||||
|
| `/etc/subuid`, `/etc/subgid` | `node:100000:65536` in both |
|
||||||
|
| user namespace | `user:[4026531837]`, identical to pid 1 — the *initial* userns |
|
||||||
|
| `unshare -U -r true` | succeeds |
|
||||||
|
| `/proc/sys/user/max_user_namespaces` | `4505` |
|
||||||
|
|
||||||
|
The one thing that is missing is in the capability bounding set that
|
||||||
|
Apple Container gives the container:
|
||||||
|
|
||||||
|
```
|
||||||
|
CapBnd: 00000000a80425fb
|
||||||
|
= chown, dac_override, fowner, fsetid, kill, setgid, setuid, setpcap,
|
||||||
|
net_bind_service, net_raw, sys_chroot, mknod, audit_write, setfcap
|
||||||
|
```
|
||||||
|
|
||||||
|
No `CAP_SYS_ADMIN`. That is the whole story, and the chain is:
|
||||||
|
|
||||||
|
1. The kernel's `map_write()` gates writing a `uid_map` on
|
||||||
|
`file_ns_capable(file, ns, CAP_SYS_ADMIN)` — capability over the
|
||||||
|
**new** user namespace, evaluated against the credentials that opened
|
||||||
|
`/proc/<pid>/uid_map`.
|
||||||
|
2. `newuidmap` is setuid-root, so it runs with euid 0 — but its
|
||||||
|
capability sets are clamped by the bounding set, which has no
|
||||||
|
`CAP_SYS_ADMIN`.
|
||||||
|
3. `cap_capable()` has a shortcut that grants *all* capabilities when
|
||||||
|
the caller's userns is the new namespace's parent **and**
|
||||||
|
`ns->owner == cred->euid`. It does not apply: the namespace was
|
||||||
|
created by `node` (uid 1000) while `newuidmap` runs as euid 0.
|
||||||
|
4. So the check falls through to the effective-set test in the initial
|
||||||
|
userns, which fails. `EPERM`.
|
||||||
|
|
||||||
|
Note that the single-line unprivileged path (`unshare -U -r`) works
|
||||||
|
precisely because it does not go through `newuidmap` and does not need
|
||||||
|
`CAP_SYS_ADMIN`. Only the multi-range subuid mapping that rootless
|
||||||
|
Docker requires does.
|
||||||
|
|
||||||
|
This is the same constraint that makes upstream's `dind-rootless` image
|
||||||
|
require `--privileged`. It is not specific to Apple Container, except
|
||||||
|
that Apple Container gives us no bounding set that includes
|
||||||
|
`CAP_SYS_ADMIN` by default.
|
||||||
|
|
||||||
|
## It does work with the capability — which is the point
|
||||||
|
|
||||||
|
Adding the capability clears the failure immediately, and exposes one
|
||||||
|
further, much smaller blocker: `/dev/net/tun` exists (the kernel has
|
||||||
|
tun; `/proc/misc` lists `200 tun`) but Apple Container creates it
|
||||||
|
`crw------- root root`, so uid 1000 cannot open it and `slirp4netns`
|
||||||
|
fails with `open: Permission denied`. A `chmod 0666 /dev/net/tun` as
|
||||||
|
root inside the bottle fixes that, and needs no capability beyond what
|
||||||
|
the bottle already has.
|
||||||
|
|
||||||
|
With both applied by hand, the daemon comes up completely:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ container run --rm -u root --cap-add CAP_SYS_ADMIN \
|
||||||
|
bot-bottle-claude:latest-rootless-docker sh -c '...'
|
||||||
|
Server Version: 20.10.24+dfsg1
|
||||||
|
Storage Driver: fuse-overlayfs
|
||||||
|
Cgroup Driver: none
|
||||||
|
Cgroup Version: 2
|
||||||
|
API listen on /tmp/rt/docker.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
So `rootless-docker-init.sh` and `rootless_docker.py` are *correct*.
|
||||||
|
The spike did not fail on a bug. It failed on its own premise.
|
||||||
|
|
||||||
|
Two secondary findings from that run, relevant if anyone revisits this:
|
||||||
|
|
||||||
|
- Debian's `docker.io` package pins Docker **20.10** (EOL), not the 28.x
|
||||||
|
implied by the `docker:28-cli` compose plugin the image copies in.
|
||||||
|
- `Cgroup Driver: none` — no resource limits on nested containers.
|
||||||
|
|
||||||
|
## Why we should not just add the capability
|
||||||
|
|
||||||
|
`CAP_SYS_ADMIN` is close to a superset of "root" in practical terms —
|
||||||
|
mount, `pivot_root`, namespace manipulation, and a long tail of
|
||||||
|
subsystem-specific powers. Granting it to the agent bottle would
|
||||||
|
undercut the containment argument the rest of the backend is built
|
||||||
|
around, including the deliberately narrow choices immediately adjacent
|
||||||
|
to it in `launch.py` (`--cap-drop CAP_NET_RAW`, no `NET_ADMIN`, a
|
||||||
|
host-only agent network). Trading all of that for nested `docker
|
||||||
|
compose` is a bad exchange.
|
||||||
|
|
||||||
|
## Podman is not blocked by this
|
||||||
|
|
||||||
|
Sanity-checked on the same host, same kernel, same runtime, so the
|
||||||
|
comparison is apples to apples:
|
||||||
|
|
||||||
|
| Scenario | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| Podman rootless, `/etc/subuid` populated | **Fails identically** — `newuidmap: write to uid_map failed: Operation not permitted` |
|
||||||
|
| Podman rootless, no subuid ranges, `--network=host` | **Works**, no added capabilities |
|
||||||
|
| Podman rootless, no subuid ranges, default netns, `/dev/net/tun` at `0600` | Fails — `slirp4netns: open("/dev/net/tun"): Permission denied` |
|
||||||
|
| Podman rootless, no subuid ranges, default netns, `/dev/net/tun` at `0666` | **Works**, no added capabilities |
|
||||||
|
|
||||||
|
The difference is that podman degrades gracefully when no subuid range
|
||||||
|
is available: it falls back to a single-UID self-mapping, which an
|
||||||
|
unprivileged process may write itself, so `newuidmap` is never invoked
|
||||||
|
and `CAP_SYS_ADMIN` is never needed. Docker's rootless mode has no
|
||||||
|
equivalent fallback.
|
||||||
|
|
||||||
|
The cost of that fallback is real and should be weighed before building
|
||||||
|
on it: with a single-UID mapping, every UID inside a nested container
|
||||||
|
collapses onto the bottle's own uid 1000. There is no UID separation
|
||||||
|
between the agent and anything it runs — `root` in a nested container is
|
||||||
|
the agent user outside it. It also requires `ignore_chown_errors` on the
|
||||||
|
storage driver. Whether that is acceptable depends on whether the bottle
|
||||||
|
boundary (which is unchanged) or the nested-container boundary (which is
|
||||||
|
effectively nil) is the one we are relying on.
|
||||||
|
|
||||||
|
## What the podman spike then needed
|
||||||
|
|
||||||
|
The podman implementation that replaced the Docker one on this branch
|
||||||
|
turned up two more device-node blockers of the same shape as
|
||||||
|
`/dev/net/tun` — Apple Container creates the node, but 0600 root:root:
|
||||||
|
|
||||||
|
- **`/dev/fuse`** — blocks the `fuse-overlayfs` storage driver
|
||||||
|
(`fuse: failed to open /dev/fuse: Permission denied`). Without it the
|
||||||
|
only working driver is `vfs`, which copies whole layers per container.
|
||||||
|
- **`/dev/net/tun`** — blocks `slirp4netns`, which rootless podman uses
|
||||||
|
for the default bridge network.
|
||||||
|
|
||||||
|
Both are fixed by `chmod 0666` as root inside the bottle, which needs no
|
||||||
|
capability the bottle does not already hold. This is categorically
|
||||||
|
different from the `CAP_SYS_ADMIN` requirement: it is a permission on a
|
||||||
|
node that already exists, not an outer privilege grant.
|
||||||
|
|
||||||
|
One design note worth recording: the agent-facing surface stays `docker`
|
||||||
|
and `docker compose`, pointed at podman's Docker-compatible API socket
|
||||||
|
via `DOCKER_HOST`. Setting `netns="host"` in `containers.conf` does *not*
|
||||||
|
propagate through that compat API — stock `docker run` and compose files
|
||||||
|
request bridge networking explicitly — so slirp4netns (and therefore the
|
||||||
|
`/dev/net/tun` chmod) is required for ordinary compose files to work at
|
||||||
|
all. Host networking remains available per-workload via
|
||||||
|
`--network=host`.
|
||||||
|
|
||||||
|
Verified working in a bottle with zero added capabilities: fuse-overlayfs
|
||||||
|
storage, the compat API socket, `docker run` on both bridge and host
|
||||||
|
networking, and published ports.
|
||||||
|
|
||||||
|
### Nested pulls collide with our own egress DLP
|
||||||
|
|
||||||
|
The first live run got podman up and `docker compose` running, then
|
||||||
|
failed on the image pull:
|
||||||
|
|
||||||
|
```
|
||||||
|
web Pulling
|
||||||
|
initializing source docker://python:3.12-alpine: reading manifest ...
|
||||||
|
StatusCode: 403, egress DLP: Generic Bearer JWT found in body
|
||||||
|
```
|
||||||
|
|
||||||
|
This is bot-bottle's own egress scanner, not a podman problem. The
|
||||||
|
Docker registry auth flow carries a bearer JWT *by protocol*, and the
|
||||||
|
`token_patterns` detector's `Generic Bearer JWT` rule
|
||||||
|
(`Bearer\s+[A-Za-z0-9._\-]{50,}`) matches it on every pull. Any bottle
|
||||||
|
that pulls images will hit this.
|
||||||
|
|
||||||
|
The fix is per-route detector scoping, which the egress config already
|
||||||
|
supports — drop `token_patterns` on the registry hosts and keep
|
||||||
|
`known_secrets`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"host": "registry-1.docker.io",
|
||||||
|
"dlp": {"outbound_detectors": ["known_secrets"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the right trade rather than a grudging one: `known_secrets`
|
||||||
|
matches the bottle's *actual* credential values, so real exfil through a
|
||||||
|
registry host is still caught. `token_patterns` on a registry route only
|
||||||
|
ever produces protocol noise.
|
||||||
|
|
||||||
|
Worth generalising later: any manifest enabling `nested_containers` needs
|
||||||
|
this on its registry routes, so it probably belongs in a shared
|
||||||
|
registry-route snippet rather than being copy-pasted per bottle.
|
||||||
|
|
||||||
|
### And then registry auth collides with the Authorization strip
|
||||||
|
|
||||||
|
With DLP scoped, the pull failed differently: `unauthorized:
|
||||||
|
authentication required`. This one is architectural.
|
||||||
|
|
||||||
|
`egress_addon.py` strips agent-set `Authorization` unconditionally
|
||||||
|
before forwarding — deliberately, so an agent cannot smuggle a
|
||||||
|
credential out in a header the DLP detectors don't recognise. A route
|
||||||
|
may carry gateway-injected auth instead, but only from a *static* token
|
||||||
|
in an env var (`auth_scheme` + `token_env`).
|
||||||
|
|
||||||
|
Docker registry auth doesn't fit that shape. The client fetches a
|
||||||
|
short-lived, per-repository-scope bearer token from `auth.docker.io` and
|
||||||
|
presents it to `registry-1.docker.io`. There is no static token to
|
||||||
|
inject, and the token the client legitimately obtained is stripped.
|
||||||
|
|
||||||
|
Measured inside a bottle, by hand:
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| Fetch token from `auth.docker.io` | 200, 5409-byte token body |
|
||||||
|
| Manifest request **with** that valid token | 401 |
|
||||||
|
| Manifest request with **no** Authorization | 401 — identical |
|
||||||
|
|
||||||
|
A valid token behaves exactly like sending none, which is direct
|
||||||
|
evidence the header never arrives. Any nested-container workflow that
|
||||||
|
pulls from a registry is blocked on this, so it is not a detail that can
|
||||||
|
be deferred: pulling base images is most of what nested containers are
|
||||||
|
for.
|
||||||
|
|
||||||
|
### Registries that skip the token dance work today
|
||||||
|
|
||||||
|
Not every registry needs the stripped header. Measured directly:
|
||||||
|
|
||||||
|
| Registry | Manifest request with no `Authorization` |
|
||||||
|
| --- | --- |
|
||||||
|
| `quay.io` | 200 |
|
||||||
|
| `mcr.microsoft.com` | 200 |
|
||||||
|
| `registry.k8s.io` | 307 (redirect, no auth) |
|
||||||
|
| `ghcr.io` | 401 |
|
||||||
|
| `registry-1.docker.io` | 401 |
|
||||||
|
|
||||||
|
So "just add the registry to the bottle config" genuinely works — for
|
||||||
|
quay, MCR, registry.k8s.io, or any unauthenticated internal registry.
|
||||||
|
Docker Hub and GHCR are the ones that need the strip resolved. The
|
||||||
|
acceptance test uses quay for exactly this reason.
|
||||||
|
|
||||||
|
Resolving it for Docker Hub means picking one of:
|
||||||
|
|
||||||
|
1. **Per-route opt-in to preserve client Authorization.** Smallest
|
||||||
|
change. Note the compounding effect on exactly these routes: the DLP
|
||||||
|
scoping above already removed `token_patterns` there, so a
|
||||||
|
preserved-auth registry route is one where the agent may send bearer
|
||||||
|
tokens that neither the strip nor the pattern detector inspects.
|
||||||
|
`known_secrets` still applies, so the bottle's real credentials are
|
||||||
|
still caught.
|
||||||
|
2. **A registry-aware gateway** that performs the token dance itself and
|
||||||
|
injects the result. Preserves the invariant fully; materially more
|
||||||
|
work, and it makes the gateway speak a specific registry protocol.
|
||||||
|
3. **Pre-seed images at provision time** (host-side `container image
|
||||||
|
save` into podman storage), so bottles never pull at runtime.
|
||||||
|
Preserves the invariant, and limits nested containers to
|
||||||
|
pre-approved images — which fits the custody positioning, at the cost
|
||||||
|
of no ad-hoc `docker pull`.
|
||||||
|
4. **Stop.** Nested containers are not supported on this backend.
|
||||||
|
|
||||||
|
### Podman 4.3.1 silently swallows container exit codes
|
||||||
|
|
||||||
|
Debian bookworm — which the current agent base image is built on —
|
||||||
|
ships podman 4.3.1. Through its Docker-compatible API, `docker run`
|
||||||
|
returns 0 no matter what the container did:
|
||||||
|
|
||||||
|
| Command | podman 4.3.1 | podman 5.4.2 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `docker run … sh -c 'exit 7'` (compat API) | **0** | 7 |
|
||||||
|
| `docker run … sh -c 'exit 0'` (compat API) | 0 | 0 |
|
||||||
|
| `podman run … sh -c 'exit 7'` (native) | 7 | 7 |
|
||||||
|
|
||||||
|
This is worse than a broken feature: every failing command an agent runs
|
||||||
|
via `docker run` reports success. A test suite, a build step, or a CI
|
||||||
|
script inside a bottle would pass while failing. It also silently
|
||||||
|
defeated the acceptance test's egress-containment assertion, which is
|
||||||
|
why that assertion now checks an in-band marker rather than an exit
|
||||||
|
code.
|
||||||
|
|
||||||
|
Podman 5.4.2 (Debian trixie) fixes it, but needs two packages that
|
||||||
|
bookworm's podman does not: `passt` (podman 5's default network tool)
|
||||||
|
and `nftables` (netavark shells out to `nft`; without it every run fails
|
||||||
|
with `unable to upgrade to tcp, received 500`). With both installed,
|
||||||
|
exit codes propagate correctly and the compat API behaves.
|
||||||
|
|
||||||
|
The open question this leaves is where podman 5 comes from, since the
|
||||||
|
agent base is bookworm-based:
|
||||||
|
|
||||||
|
1. **Move the agent images to Debian trixie.** Trixie is current stable.
|
||||||
|
Correct, and the blast radius is every bottle, not just this feature.
|
||||||
|
2. **Drop the compat socket and use podman natively** (`podman-docker`
|
||||||
|
provides a `docker` shim; compose comes from `podman-compose`).
|
||||||
|
Native podman propagates exit codes correctly even on 4.3.1. Contained
|
||||||
|
to this feature, at the cost of `docker compose` becoming
|
||||||
|
`docker-compose`/`podman-compose`.
|
||||||
|
3. **Ship bookworm's podman 4.3.1 with the compat socket** — not viable.
|
||||||
|
Silent false success is a correctness bug agents cannot see.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
1. Do not revive rootless Docker on this backend. This document is the
|
||||||
|
record of why.
|
||||||
|
2. Nested containers, if wanted, come from podman under the
|
||||||
|
single-mapping constraint — with the explicit understanding that the
|
||||||
|
nested-container boundary carries no security weight. `root` in a
|
||||||
|
nested container is the agent user outside it.
|
||||||
|
3. Nested containers are therefore a build/test convenience. The bottle
|
||||||
|
remains the security boundary, exactly as it was.
|
||||||
@@ -21,7 +21,7 @@ from bot_bottle.backend.firecracker import FirecrackerBottleBackend
|
|||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _manifest() -> ManifestIndex:
|
def _manifest(*, nested_containers: bool = False) -> ManifestIndex:
|
||||||
return ManifestIndex.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
@@ -29,6 +29,7 @@ def _manifest() -> ManifestIndex:
|
|||||||
"LITERAL_ENV": "literal-value",
|
"LITERAL_ENV": "literal-value",
|
||||||
"FORWARDED_ENV": "${HOST_SECRET_ENV}",
|
"FORWARDED_ENV": "${HOST_SECRET_ENV}",
|
||||||
},
|
},
|
||||||
|
"nested_containers": nested_containers,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -41,9 +42,11 @@ def _manifest() -> ManifestIndex:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _spec(tmp: Path, *, identity: str) -> BottleSpec:
|
def _spec(
|
||||||
|
tmp: Path, *, identity: str, nested_containers: bool = False,
|
||||||
|
) -> BottleSpec:
|
||||||
return BottleSpec(
|
return BottleSpec(
|
||||||
manifest=_manifest(),
|
manifest=_manifest(nested_containers=nested_containers),
|
||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=str(tmp),
|
user_cwd=str(tmp),
|
||||||
@@ -113,6 +116,42 @@ class TestFirecrackerPrepare(_FakeStateMixin, unittest.TestCase):
|
|||||||
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
|
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNestedContainersRejection(_FakeStateMixin, unittest.TestCase):
|
||||||
|
"""A backend with no guest-local engine must refuse the flag outright.
|
||||||
|
|
||||||
|
Ignoring it would leave the agent without `docker`, and the only ways to
|
||||||
|
fake it here (a host daemon socket, a privileged container) are what
|
||||||
|
issue #392 rules out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_docker_backend_refuses_the_flag(self) -> None:
|
||||||
|
backend = DockerBottleBackend()
|
||||||
|
spec = _spec(
|
||||||
|
Path(self.tmp.name), identity="demo-docker", nested_containers=True,
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"bot_bottle.backend.resolve_common.die", side_effect=RuntimeError,
|
||||||
|
) as die,
|
||||||
|
self.assertRaises(RuntimeError),
|
||||||
|
):
|
||||||
|
backend.prepare(spec, Path(self.tmp.name) / "stage")
|
||||||
|
self.assertIn("nested_containers", die.call_args.args[0])
|
||||||
|
self.assertIn("docker", die.call_args.args[0])
|
||||||
|
|
||||||
|
def test_firecracker_backend_refuses_the_flag(self) -> None:
|
||||||
|
backend = FirecrackerBottleBackend()
|
||||||
|
spec = _spec(Path(self.tmp.name), identity="demo-fc", nested_containers=True)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"bot_bottle.backend.resolve_common.die", side_effect=RuntimeError,
|
||||||
|
) as die,
|
||||||
|
self.assertRaises(RuntimeError),
|
||||||
|
):
|
||||||
|
backend.prepare(spec, Path(self.tmp.name) / "stage")
|
||||||
|
self.assertIn("firecracker", die.call_args.args[0])
|
||||||
|
|
||||||
|
|
||||||
class TestMintSlug(unittest.TestCase):
|
class TestMintSlug(unittest.TestCase):
|
||||||
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
|
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
|
||||||
manifest = _manifest()
|
manifest = _manifest()
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ class TestBuiltinAgentImages(unittest.TestCase):
|
|||||||
r"(?m)^FROM node:22-trixie-slim\s*$",
|
r"(?m)^FROM node:22-trixie-slim\s*$",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_all_install_podman(self):
|
def test_none_install_podman(self):
|
||||||
|
# podman lives in the nested-containers derived layer (nested_containers.py),
|
||||||
|
# not in the base agent images, so bottles without the flag pay no cost.
|
||||||
for dockerfile in _AGENT_DOCKERFILES:
|
for dockerfile in _AGENT_DOCKERFILES:
|
||||||
with self.subTest(provider=dockerfile.parent.name):
|
with self.subTest(provider=dockerfile.parent.name):
|
||||||
self.assertRegex(
|
self.assertNotRegex(
|
||||||
dockerfile.read_text(),
|
dockerfile.read_text(),
|
||||||
re.compile(r"(?m)^\s*podman(?:\s|\\|$)"),
|
re.compile(r"(?m)^\s*podman(?:\s|\\|$)"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -379,6 +379,25 @@ class TestRenderRoutes(unittest.TestCase):
|
|||||||
addon_routes = load_config(rendered).routes
|
addon_routes = load_config(rendered).routes
|
||||||
self.assertTrue(addon_routes[0].git_fetch)
|
self.assertTrue(addon_routes[0].git_fetch)
|
||||||
|
|
||||||
|
def test_preserve_auth_round_trips_to_the_addon(self):
|
||||||
|
"""Regression: the manifest parsed preserve_auth and the addon honored
|
||||||
|
it, but the renderer in between dropped it — so the flag never reached
|
||||||
|
the proxy and registry pulls kept failing with "unauthorized" while
|
||||||
|
the config looked correct everywhere it was inspected."""
|
||||||
|
from bot_bottle.egress_addon_core import load_config
|
||||||
|
b = _bottle([{"host": "registry-1.docker.io", "preserve_auth": True}])
|
||||||
|
routes = egress_routes_for_bottle(b)
|
||||||
|
rendered = egress_render_routes(routes)
|
||||||
|
self.assertIn("preserve_auth: true", rendered)
|
||||||
|
self.assertTrue(load_config(rendered).routes[0].preserve_auth)
|
||||||
|
|
||||||
|
def test_preserve_auth_omitted_when_unset(self):
|
||||||
|
b = _bottle([{"host": "x.example"}])
|
||||||
|
rendered = egress_render_routes(egress_routes_for_bottle(b))
|
||||||
|
self.assertNotIn("preserve_auth", rendered)
|
||||||
|
from bot_bottle.egress_addon_core import load_config
|
||||||
|
self.assertFalse(load_config(rendered).routes[0].preserve_auth)
|
||||||
|
|
||||||
def test_log_zero_omitted_from_render(self):
|
def test_log_zero_omitted_from_render(self):
|
||||||
b = _bottle([{"host": "x.example"}])
|
b = _bottle([{"host": "x.example"}])
|
||||||
routes = egress_routes_for_bottle(b)
|
routes = egress_routes_for_bottle(b)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ classmethods forward to their module.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.backend.firecracker import cleanup as fc_cleanup
|
from bot_bottle.backend.firecracker import cleanup as fc_cleanup
|
||||||
@@ -23,32 +25,69 @@ def _proc(stdout: str = "", returncode: int = 0) -> "subprocess.CompletedProcess
|
|||||||
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr="")
|
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr="")
|
||||||
|
|
||||||
|
|
||||||
class TestOrphanEnumeration(unittest.TestCase):
|
class TestProcessScan(unittest.TestCase):
|
||||||
def test_orphan_vm_pids_filters_by_run_dir(self):
|
def test_run_dir_of_matches_only_direct_children(self):
|
||||||
run_root = str(fc_cleanup._run_root())
|
run_root = Path("/cache/run")
|
||||||
|
self.assertEqual(
|
||||||
|
Path("/cache/run/dev-a"),
|
||||||
|
fc_cleanup._run_dir_of(
|
||||||
|
f"firecracker --config-file {run_root}/dev-a/config.json", run_root
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# infra/builder VMs elsewhere, or nested paths, are not ours.
|
||||||
|
self.assertIsNone(
|
||||||
|
fc_cleanup._run_dir_of("firecracker --config-file /elsewhere/config.json", run_root)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
fc_cleanup._run_dir_of("firecracker --no-config", run_root)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scan_splits_live_dirs_from_orphan_pids(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
run_root = Path(tmp)
|
||||||
|
(run_root / "live-a").mkdir() # dir present -> live VM, protected
|
||||||
|
# "gone-b" dir intentionally absent -> lingering VMM, orphan pid
|
||||||
out = (
|
out = (
|
||||||
f"111 firecracker --config-file {run_root}/dev-a/config.json\n"
|
f"111 firecracker --config-file {run_root}/live-a/config.json\n"
|
||||||
"222 firecracker --config-file /somewhere/else/config.json\n"
|
f"222 firecracker --config-file {run_root}/gone-b/config.json\n"
|
||||||
"notanint firecracker --config-file " + run_root + "/x\n"
|
"333 firecracker --config-file /elsewhere/config.json\n"
|
||||||
|
"notanint firecracker --config-file x\n"
|
||||||
)
|
)
|
||||||
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
|
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
|
||||||
self.assertEqual([111], fc_cleanup._orphan_vm_pids())
|
live, orphan_pids = fc_cleanup._scan_processes(run_root)
|
||||||
|
self.assertEqual({str(run_root / "live-a")}, live)
|
||||||
|
self.assertEqual([222], orphan_pids)
|
||||||
|
|
||||||
def test_orphan_vm_pids_empty_when_pgrep_fails(self):
|
def test_scan_empty_when_pgrep_fails(self):
|
||||||
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
with patch.object(fc_cleanup.subprocess, "run", return_value=_proc(returncode=1)):
|
||||||
self.assertEqual([], fc_cleanup._orphan_vm_pids())
|
self.assertEqual((set(), []), fc_cleanup._scan_processes(Path("/x")))
|
||||||
|
|
||||||
def test_run_dirs_empty_when_absent(self):
|
def test_orphan_run_dirs_excludes_live_and_missing_root(self):
|
||||||
with patch.object(fc_cleanup.util, "cache_dir") as cache:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
cache.return_value.__truediv__.return_value.is_dir.return_value = False
|
run_root = Path(tmp)
|
||||||
self.assertEqual([], fc_cleanup._run_dirs())
|
(run_root / "live-a").mkdir()
|
||||||
|
(run_root / "dead-b").mkdir()
|
||||||
|
live = {str(run_root / "live-a")}
|
||||||
|
self.assertEqual(
|
||||||
|
[str(run_root / "dead-b")],
|
||||||
|
fc_cleanup._orphan_run_dirs(run_root, live),
|
||||||
|
)
|
||||||
|
# absent run root -> nothing to reap
|
||||||
|
self.assertEqual([], fc_cleanup._orphan_run_dirs(Path("/nope/run"), set()))
|
||||||
|
|
||||||
def test_prepare_cleanup_assembles_plan(self):
|
def test_prepare_cleanup_reaps_orphans_only(self):
|
||||||
with patch.object(fc_cleanup, "_orphan_vm_pids", return_value=[7]), \
|
"""The live VM's dir is never in the plan; the dead one is."""
|
||||||
patch.object(fc_cleanup, "_run_dirs", return_value=["/run/x"]):
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
run_root = Path(tmp)
|
||||||
|
(run_root / "live-a").mkdir()
|
||||||
|
(run_root / "dead-b").mkdir()
|
||||||
|
out = f"111 firecracker --config-file {run_root}/live-a/config.json\n"
|
||||||
|
with patch.object(fc_cleanup, "_run_root", return_value=run_root), \
|
||||||
|
patch.object(fc_cleanup.subprocess, "run", return_value=_proc(out)):
|
||||||
plan = fc_cleanup.prepare_cleanup()
|
plan = fc_cleanup.prepare_cleanup()
|
||||||
self.assertEqual((7,), plan.vm_pids)
|
self.assertEqual((), plan.vm_pids)
|
||||||
self.assertEqual(("/run/x",), plan.run_dirs)
|
self.assertEqual((str(run_root / "dead-b"),), plan.run_dirs)
|
||||||
|
self.assertNotIn(str(run_root / "live-a"), plan.run_dirs)
|
||||||
|
|
||||||
|
|
||||||
class TestCleanupRemoval(unittest.TestCase):
|
class TestCleanupRemoval(unittest.TestCase):
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def _plan(
|
|||||||
agent_git_gate_url: str = "",
|
agent_git_gate_url: str = "",
|
||||||
agent_supervise_url: str = "",
|
agent_supervise_url: str = "",
|
||||||
image_policy: str = "fresh",
|
image_policy: str = "fresh",
|
||||||
|
nested_containers: bool = False,
|
||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
routes_path = stage_dir / "routes.yaml"
|
routes_path = stage_dir / "routes.yaml"
|
||||||
routes_path.write_text("routes: []\n", encoding="utf-8")
|
routes_path.write_text("routes: []\n", encoding="utf-8")
|
||||||
@@ -67,6 +68,7 @@ def _plan(
|
|||||||
manifest=_MANIFEST,
|
manifest=_MANIFEST,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
slug="dev-abc",
|
slug="dev-abc",
|
||||||
|
nested_containers=nested_containers,
|
||||||
container_name="bot-bottle-dev-abc",
|
container_name="bot-bottle-dev-abc",
|
||||||
image="bot-bottle-agent:latest",
|
image="bot-bottle-agent:latest",
|
||||||
dockerfile_path="/repo/Dockerfile",
|
dockerfile_path="/repo/Dockerfile",
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""Unit coverage for the fail-closed guest-local container engine (#392)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle.backend.macos_container import nested_containers
|
||||||
|
from bot_bottle.backend.macos_container import launch as launch_mod
|
||||||
|
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _AgentProvision:
|
||||||
|
image: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Spec:
|
||||||
|
image_policy: str = "build"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Plan:
|
||||||
|
slug: str
|
||||||
|
image: str
|
||||||
|
dockerfile_path: str
|
||||||
|
nested_containers: bool
|
||||||
|
agent_provision: _AgentProvision
|
||||||
|
spec: _Spec = _Spec()
|
||||||
|
|
||||||
|
|
||||||
|
def _base_image_only(ref: str) -> bool:
|
||||||
|
"""Only the un-derived agent image is cached on the host."""
|
||||||
|
return not ref.endswith(nested_containers.IMAGE_SUFFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(**kwargs: object) -> MacosContainerBottlePlan:
|
||||||
|
return cast(MacosContainerBottlePlan, cast(object, _Plan(**kwargs))) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNestedContainersStart(unittest.TestCase):
|
||||||
|
def test_bootstraps_then_waits_for_guest_local_service(self) -> None:
|
||||||
|
bottle = _Bottle([_result(0), _result(1), _result(0)])
|
||||||
|
with patch.object(nested_containers.time, "sleep"):
|
||||||
|
nested_containers.start(bottle)
|
||||||
|
self.assertIn("nested-containers-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="slirp4netns missing")])
|
||||||
|
with patch.object(nested_containers, "die", side_effect=RuntimeError) as die:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
nested_containers.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(nested_containers.READY_RETRIES)]
|
||||||
|
+ [_result(0, stdout="operation not permitted")]
|
||||||
|
)
|
||||||
|
with patch.object(nested_containers.time, "sleep"), \
|
||||||
|
patch.object(nested_containers, "die", side_effect=RuntimeError) as die:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
nested_containers.start(bottle)
|
||||||
|
self.assertIn("operation not permitted", die.call_args.args[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestNestedContainersDevices(unittest.TestCase):
|
||||||
|
def test_relaxes_only_the_two_blocked_device_nodes_as_root(self) -> None:
|
||||||
|
calls: list[tuple[str, list[str]]] = []
|
||||||
|
|
||||||
|
def record(name: str, argv: list[str]) -> None:
|
||||||
|
calls.append((name, argv))
|
||||||
|
|
||||||
|
nested_containers.prepare_guest_devices("bottle-1", record)
|
||||||
|
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 TestNestedContainersImage(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("aardvark-dns fuse-overlayfs netavark nftables passt podman", text)
|
||||||
|
self.assertIn("USER node", text)
|
||||||
|
self.assertTrue((Path(context) / "nested-containers-init.sh").is_file())
|
||||||
|
|
||||||
|
image = nested_containers.build_image("agent:base", build)
|
||||||
|
self.assertEqual("agent:base-nested-containers", image)
|
||||||
|
self.assertEqual("agent:base-nested-containers", calls[0][0])
|
||||||
|
|
||||||
|
def test_installs_the_whole_podman_5_networking_stack(self) -> None:
|
||||||
|
"""Each missing piece fails at a different, misleading layer: no pasta
|
||||||
|
and nothing starts; no nft and netavark cannot build the bridge that
|
||||||
|
compose expects; no aardvark-dns and DNS inside nested containers
|
||||||
|
fails while everything else looks healthy. Image pulls keep working
|
||||||
|
throughout, which is what made these read as compat-API bugs."""
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def build(_image: str, _context: str, *, dockerfile: str) -> None:
|
||||||
|
seen.append(Path(dockerfile).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
nested_containers.build_image("agent:base", build)
|
||||||
|
for package in ("podman", "passt", "nftables", "aardvark-dns"):
|
||||||
|
self.assertIn(package, seen[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"))
|
||||||
|
|
||||||
|
nested_containers.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", "",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitScript(unittest.TestCase):
|
||||||
|
"""The bootstrap runs inside the bottle, so its wiring is only checkable
|
||||||
|
here or on a live macOS host."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.script = (
|
||||||
|
Path(nested_containers.__file__).with_name("nested-containers-init.sh")
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ca_bundle_path_matches_the_backend_constant(self) -> None:
|
||||||
|
from bot_bottle.backend.util import AGENT_CA_BUNDLE
|
||||||
|
self.assertIn(f'CA_BUNDLE="{AGENT_CA_BUNDLE}"', self.script)
|
||||||
|
|
||||||
|
def test_resolves_the_gateway_address_for_nested_containers(self) -> None:
|
||||||
|
"""podman's hosts_file only applies to native `podman run` — the
|
||||||
|
Docker-compat API ignores it, and the agent types `docker`. So the
|
||||||
|
gateway name is resolved here and the *address* goes into the proxy
|
||||||
|
URL; otherwise every nested container dies at "Could not resolve
|
||||||
|
proxy", which reads like broken DNS but is a missing hosts entry."""
|
||||||
|
self.assertIn('$2 == name { print $1; exit }', self.script)
|
||||||
|
self.assertIn('value.replace(name, ip)', self.script)
|
||||||
|
|
||||||
|
def test_docker_cli_proxy_config_uses_the_address(self) -> None:
|
||||||
|
"""The Docker CLI copies ~/.docker/config.json's proxies block into
|
||||||
|
every container it starts. Being client-side it beats the podman
|
||||||
|
service, so this — not containers.conf — is what decides whether a
|
||||||
|
nested container can reach the proxy."""
|
||||||
|
block = self.script[self.script.index('"proxies"'):]
|
||||||
|
self.assertIn('"httpProxy": proxy.replace(name, ip)', block)
|
||||||
|
self.assertIn('"httpsProxy": proxy.replace(name, ip)', block)
|
||||||
|
self.assertIn('"noProxy": no_proxy', block)
|
||||||
|
|
||||||
|
def test_substitutes_the_address_into_the_service_environment(self) -> None:
|
||||||
|
"""Covers what the Docker CLI does not stamp: podman's own registry
|
||||||
|
pulls, and containers created through the API by another client."""
|
||||||
|
launch = self.script[self.script.index("podman system service"):]
|
||||||
|
self.assertNotIn("$GATEWAY_NAME", launch) # substitution precedes it
|
||||||
|
setup = self.script[:self.script.index("podman system service")]
|
||||||
|
self.assertIn("for var in HTTP_PROXY HTTPS_PROXY http_proxy https_proxy",
|
||||||
|
setup)
|
||||||
|
|
||||||
|
def test_disables_podmans_own_proxy_passthrough(self) -> None:
|
||||||
|
"""podman copies the host's proxy vars into every container by
|
||||||
|
default, and that copy overrides the env we set — putting the
|
||||||
|
unresolvable gateway name back and leaving nested containers at
|
||||||
|
"bad address 'bot-bottle-gateway'"."""
|
||||||
|
self.assertIn('"http_proxy=false"', self.script)
|
||||||
|
|
||||||
|
def test_keeps_the_gateway_name_in_no_proxy(self) -> None:
|
||||||
|
"""NO_PROXY is matched against what a client asks for, and code inside
|
||||||
|
a nested container still says bot-bottle-gateway."""
|
||||||
|
start = self.script.index('for var in ("NO_PROXY"')
|
||||||
|
loop = self.script[start:self.script.index("path = Path(", start)]
|
||||||
|
self.assertIn('entries.append(f"{var}={value}")', loop)
|
||||||
|
self.assertNotIn("replace(name, ip)", loop)
|
||||||
|
self.assertIn('"noProxy": no_proxy', self.script)
|
||||||
|
|
||||||
|
def test_fails_closed_without_a_gateway_address(self) -> None:
|
||||||
|
self.assertIn('[ -n "$gateway_ip" ] || {', self.script)
|
||||||
|
|
||||||
|
def test_mounts_and_trusts_the_gateway_ca(self) -> None:
|
||||||
|
"""The gateway TLS-intercepts, so without the bundle every HTTPS call
|
||||||
|
from a nested container fails with "unable to get local issuer
|
||||||
|
certificate"."""
|
||||||
|
self.assertIn('volumes=["{ca}:{ca}:ro"]', self.script)
|
||||||
|
for var in (
|
||||||
|
"SSL_CERT_FILE", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE",
|
||||||
|
"NODE_EXTRA_CA_CERTS",
|
||||||
|
):
|
||||||
|
self.assertIn(f'f"{var}={{ca}}"', self.script)
|
||||||
|
|
||||||
|
def test_writes_the_token_bearing_config_unreadable_to_others(self) -> None:
|
||||||
|
"""The proxy URL carries the bottle's identity token."""
|
||||||
|
self.assertIn("path.chmod(0o600)", self.script)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuestEnvironment(unittest.TestCase):
|
||||||
|
def test_disabled_bottle_gets_no_docker_environment(self) -> None:
|
||||||
|
self.assertEqual({}, nested_containers.guest_env(False))
|
||||||
|
|
||||||
|
def test_enabled_bottle_uses_only_the_guest_local_socket(self) -> None:
|
||||||
|
env = nested_containers.guest_env(True)
|
||||||
|
self.assertEqual("unix:///tmp/bbp/podman.sock", env["DOCKER_HOST"])
|
||||||
|
self.assertNotIn("/var/run/docker.sock", " ".join(env.values()))
|
||||||
|
|
||||||
|
def test_runtime_dir_leaves_room_for_conmons_attach_socket(self) -> None:
|
||||||
|
"""podman builds `$XDG_RUNTIME_DIR/libpod/tmp/socket/<64-hex>/attach`,
|
||||||
|
which must fit in sun_path (108 bytes). A descriptive runtime dir blew
|
||||||
|
past it and every attached `docker run` failed with "unable to upgrade
|
||||||
|
to tcp, received 500" while pulls and detached runs looked fine."""
|
||||||
|
attach = (
|
||||||
|
nested_containers.guest_env(True)["XDG_RUNTIME_DIR"]
|
||||||
|
+ "/libpod/tmp/socket/" + "a" * 64 + "/attach"
|
||||||
|
)
|
||||||
|
self.assertLessEqual(len(attach), 107, attach)
|
||||||
|
|
||||||
|
def test_macos_backend_declares_support(self) -> None:
|
||||||
|
from bot_bottle.backend.macos_container.backend import (
|
||||||
|
MacosContainerBottleBackend,
|
||||||
|
)
|
||||||
|
self.assertTrue(MacosContainerBottleBackend.supports_nested_containers)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildOrLoadImages(unittest.TestCase):
|
||||||
|
def test_disabled_bottle_keeps_the_plain_agent_image(self) -> None:
|
||||||
|
plan = _plan(
|
||||||
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
||||||
|
nested_containers=False, agent_provision=_AgentProvision("agent:base"),
|
||||||
|
)
|
||||||
|
with patch.object(launch_mod, "read_committed_image", return_value=None), \
|
||||||
|
patch.object(launch_mod.container_mod, "build_image"), \
|
||||||
|
patch.object(launch_mod.nested_containers_mod, "build_image") as derived:
|
||||||
|
images = launch_mod.build_or_load_images(plan)
|
||||||
|
derived.assert_not_called()
|
||||||
|
self.assertEqual("agent:base", images.agent)
|
||||||
|
|
||||||
|
def test_enabled_bottle_builds_base_then_derived_variant(self) -> None:
|
||||||
|
plan = _plan(
|
||||||
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
||||||
|
nested_containers=True, agent_provision=_AgentProvision("agent:base"),
|
||||||
|
)
|
||||||
|
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.nested_containers_mod,
|
||||||
|
"build_image",
|
||||||
|
return_value="agent:base-nested-containers",
|
||||||
|
) as derived:
|
||||||
|
images = launch_mod.build_or_load_images(plan)
|
||||||
|
|
||||||
|
build.assert_called_once_with(
|
||||||
|
"agent:base", launch_mod._REPO_DIR, # pylint: disable=protected-access
|
||||||
|
dockerfile="/repo/Dockerfile",
|
||||||
|
)
|
||||||
|
derived.assert_called_once_with("agent:base", build)
|
||||||
|
self.assertEqual("agent:base-nested-containers", images.agent)
|
||||||
|
|
||||||
|
def test_derived_image_layers_onto_a_committed_image(self) -> None:
|
||||||
|
plan = _plan(
|
||||||
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
||||||
|
nested_containers=True, agent_provision=_AgentProvision("agent:base"),
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
launch_mod, "read_committed_image", return_value="agent:committed",
|
||||||
|
), \
|
||||||
|
patch.object(launch_mod.container_mod, "image_exists", return_value=True), \
|
||||||
|
patch.object(launch_mod.container_mod, "build_image") as build, \
|
||||||
|
patch.object(
|
||||||
|
launch_mod.nested_containers_mod,
|
||||||
|
"build_image",
|
||||||
|
return_value="agent:committed-nested-containers",
|
||||||
|
) as derived:
|
||||||
|
images = launch_mod.build_or_load_images(plan)
|
||||||
|
|
||||||
|
build.assert_not_called()
|
||||||
|
derived.assert_called_once_with("agent:committed", build)
|
||||||
|
self.assertEqual("agent:committed-nested-containers", images.agent)
|
||||||
|
|
||||||
|
def test_cached_policy_refuses_to_build_the_derived_image(self) -> None:
|
||||||
|
plan = _plan(
|
||||||
|
slug="dev-abc", image="agent:base", dockerfile_path="/repo/Dockerfile",
|
||||||
|
nested_containers=True, agent_provision=_AgentProvision("agent:base"),
|
||||||
|
spec=_Spec(image_policy="cached"),
|
||||||
|
)
|
||||||
|
with patch.object(launch_mod, "read_committed_image", return_value=None), \
|
||||||
|
patch.object(
|
||||||
|
launch_mod.container_mod, "image_exists",
|
||||||
|
side_effect=_base_image_only,
|
||||||
|
), \
|
||||||
|
patch.object(launch_mod.nested_containers_mod, "build_image") as derived, \
|
||||||
|
patch.object(launch_mod, "die", side_effect=RuntimeError) as die:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
launch_mod.build_or_load_images(plan)
|
||||||
|
derived.assert_not_called()
|
||||||
|
self.assertIn("agent:base-nested-containers", die.call_args.args[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -56,6 +56,56 @@ class TestMergeBottlesRuntime(unittest.TestCase):
|
|||||||
result = merge_bottles_runtime([base, override])
|
result = merge_bottles_runtime([base, override])
|
||||||
self.assertFalse(result.supervise)
|
self.assertFalse(result.supervise)
|
||||||
|
|
||||||
|
def test_supervise_survives_a_later_bottle_that_omits_the_key(self):
|
||||||
|
disabled = _bottle(supervise=False)
|
||||||
|
quiet = _bottle(env={"X": "1"})
|
||||||
|
self.assertFalse(merge_bottles_runtime([disabled, quiet]).supervise)
|
||||||
|
|
||||||
|
def test_supervise_explicit_true_overrides_earlier_false(self):
|
||||||
|
disabled = _bottle(supervise=False)
|
||||||
|
enabled = _bottle(supervise=True)
|
||||||
|
self.assertTrue(merge_bottles_runtime([disabled, enabled]).supervise)
|
||||||
|
|
||||||
|
def test_nested_containers_survives_a_later_bottle_that_omits_the_key(self):
|
||||||
|
"""A bottle that never mentions nested_containers must not silently
|
||||||
|
drop the capability: `--bottle with-docker --bottle claude-dev`."""
|
||||||
|
enabled = _bottle(nested_containers=True)
|
||||||
|
quiet = _bottle(env={"X": "1"})
|
||||||
|
self.assertTrue(merge_bottles_runtime([enabled, quiet]).nested_containers)
|
||||||
|
self.assertTrue(merge_bottles_runtime([quiet, enabled]).nested_containers)
|
||||||
|
self.assertFalse(merge_bottles_runtime([quiet, quiet]).nested_containers)
|
||||||
|
|
||||||
|
def test_nested_containers_explicit_false_overrides_earlier_true(self):
|
||||||
|
"""An explicit nested_containers: false in a later bottle must win
|
||||||
|
over an earlier true (last-wins, presence-aware)."""
|
||||||
|
enabled = _bottle(nested_containers=True)
|
||||||
|
disabled = _bottle(nested_containers=False)
|
||||||
|
self.assertFalse(merge_bottles_runtime([enabled, disabled]).nested_containers)
|
||||||
|
|
||||||
|
def test_nested_containers_explicit_true_overrides_earlier_false(self):
|
||||||
|
enabled = _bottle(nested_containers=True)
|
||||||
|
disabled = _bottle(nested_containers=False)
|
||||||
|
self.assertTrue(merge_bottles_runtime([disabled, enabled]).nested_containers)
|
||||||
|
|
||||||
|
def test_extended_boolean_values_remain_explicit_at_runtime(self):
|
||||||
|
idx = _index(
|
||||||
|
bottles={
|
||||||
|
"enabled": {"nested_containers": True, "supervise": True},
|
||||||
|
"parent": {},
|
||||||
|
"disabled_child": {
|
||||||
|
"extends": "parent",
|
||||||
|
"nested_containers": False,
|
||||||
|
"supervise": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents={"impl": {"bottle": "enabled", "skills": [], "prompt": ""}},
|
||||||
|
)
|
||||||
|
result = idx.load_for_agent(
|
||||||
|
"impl", ("enabled", "disabled_child")
|
||||||
|
).bottle
|
||||||
|
self.assertFalse(result.nested_containers)
|
||||||
|
self.assertFalse(result.supervise)
|
||||||
|
|
||||||
def test_three_bottles_merged_left_to_right(self):
|
def test_three_bottles_merged_left_to_right(self):
|
||||||
b1 = _bottle(env={"A": "1", "B": "1", "C": "1"})
|
b1 = _bottle(env={"A": "1", "B": "1", "C": "1"})
|
||||||
b2 = _bottle(env={"B": "2", "C": "2"})
|
b2 = _bottle(env={"B": "2", "C": "2"})
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ class TestExtendsBasic(unittest.TestCase):
|
|||||||
self.assertTrue(m.bottles["base"].supervise)
|
self.assertTrue(m.bottles["base"].supervise)
|
||||||
self.assertFalse(m.bottles["off"].supervise)
|
self.assertFalse(m.bottles["off"].supervise)
|
||||||
|
|
||||||
|
def test_child_overrides_nested_containers_scalar(self):
|
||||||
|
m = _build(
|
||||||
|
base={"nested_containers": True},
|
||||||
|
off={"extends": "base", "nested_containers": False},
|
||||||
|
)
|
||||||
|
self.assertTrue(m.bottles["base"].nested_containers)
|
||||||
|
self.assertFalse(m.bottles["off"].nested_containers)
|
||||||
|
|
||||||
|
def test_inherited_boolean_declaration_is_preserved(self):
|
||||||
|
m = _build(
|
||||||
|
base={"nested_containers": True, "supervise": False},
|
||||||
|
child={"extends": "base"},
|
||||||
|
)
|
||||||
|
child = m.bottles["child"]
|
||||||
|
self.assertIn("nested_containers", child.declared_fields)
|
||||||
|
self.assertIn("supervise", child.declared_fields)
|
||||||
|
|
||||||
def test_parent_resolved_once_for_multiple_children(self):
|
def test_parent_resolved_once_for_multiple_children(self):
|
||||||
# Two children sharing one parent: both inherit; the parent
|
# Two children sharing one parent: both inherit; the parent
|
||||||
# is resolved once + cached. (Cache behavior is internal; we
|
# is resolved once + cached. (Cache behavior is internal; we
|
||||||
@@ -477,6 +494,26 @@ class TestExtendsMultiParent(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(m.bottles["child"].supervise)
|
self.assertTrue(m.bottles["child"].supervise)
|
||||||
|
|
||||||
|
def test_later_parent_omitting_boole_preserves_earlier_values(self):
|
||||||
|
m = _build(
|
||||||
|
p1={"nested_containers": True, "supervise": False},
|
||||||
|
p2={"env": {"FROM_P2": "1"}},
|
||||||
|
child={"extends": ["p1", "p2"]},
|
||||||
|
)
|
||||||
|
child = m.bottles["child"]
|
||||||
|
self.assertTrue(child.nested_containers)
|
||||||
|
self.assertFalse(child.supervise)
|
||||||
|
|
||||||
|
def test_later_parent_explicit_boole_override_earlier_values(self):
|
||||||
|
m = _build(
|
||||||
|
p1={"nested_containers": True, "supervise": False},
|
||||||
|
p2={"nested_containers": False, "supervise": True},
|
||||||
|
child={"extends": ["p1", "p2"]},
|
||||||
|
)
|
||||||
|
child = m.bottles["child"]
|
||||||
|
self.assertFalse(child.nested_containers)
|
||||||
|
self.assertTrue(child.supervise)
|
||||||
|
|
||||||
def test_child_supervise_overrides_all_parents(self):
|
def test_child_supervise_overrides_all_parents(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
p1={"supervise": True},
|
p1={"supervise": True},
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ class TestBottleValidation(unittest.TestCase):
|
|||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestBottle.from_dict("b", {"supervise": "yes"})
|
ManifestBottle.from_dict("b", {"supervise": "yes"})
|
||||||
|
|
||||||
|
def test_nested_containers_not_bool(self) -> None:
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
ManifestBottle.from_dict("b", {"nested_containers": "yes"})
|
||||||
|
|
||||||
|
def test_nested_containers_defaults_off(self) -> None:
|
||||||
|
self.assertFalse(ManifestBottle.from_dict("b", {}).nested_containers)
|
||||||
|
self.assertTrue(
|
||||||
|
ManifestBottle.from_dict("b", {"nested_containers": True})
|
||||||
|
.nested_containers
|
||||||
|
)
|
||||||
|
|
||||||
def test_removed_runtime_field(self) -> None:
|
def test_removed_runtime_field(self) -> None:
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestBottle.from_dict("b", {"runtime": "runsc"})
|
ManifestBottle.from_dict("b", {"runtime": "runsc"})
|
||||||
|
|||||||
Reference in New Issue
Block a user