diff --git a/bot_bottle/backend/macos_container/backend.py b/bot_bottle/backend/macos_container/backend.py index 1f6423b..8826aa3 100644 --- a/bot_bottle/backend/macos_container/backend.py +++ b/bot_bottle/backend/macos_container/backend.py @@ -79,3 +79,6 @@ class MacosContainerBottleBackend( def enumerate_active(self) -> Sequence[ActiveAgent]: return _enumerate.enumerate_active() + + def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str: + return plan.agent_supervise_url diff --git a/bot_bottle/backend/macos_container/bottle_plan.py b/bot_bottle/backend/macos_container/bottle_plan.py index ca01073..d128d55 100644 --- a/bot_bottle/backend/macos_container/bottle_plan.py +++ b/bot_bottle/backend/macos_container/bottle_plan.py @@ -13,6 +13,9 @@ from .. import BottlePlan class MacosContainerBottlePlan(BottlePlan): slug: str forwarded_env: dict[str, str] = field(repr=False) + agent_proxy_url: str = "" + agent_git_gate_url: str = "" + agent_supervise_url: str = "" @property def container_name(self) -> str: diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py index d6365a1..b7d261d 100644 --- a/bot_bottle/backend/macos_container/enumerate.py +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -8,6 +8,7 @@ from ...bottle_state import read_metadata from .. import ActiveAgent _PREFIX = "bot-bottle-" +_SIDECAR_PREFIX = "bot-bottle-sidecars-" def enumerate_active() -> list[ActiveAgent]: @@ -23,6 +24,8 @@ def enumerate_active() -> list[ActiveAgent]: for name in sorted(line.strip() for line in result.stdout.splitlines()): if not name.startswith(_PREFIX): continue + if name.startswith(_SIDECAR_PREFIX): + continue slug = name[len(_PREFIX):] metadata = read_metadata(slug) out.append(ActiveAgent( diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index de9e053..a7dfea3 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -1,34 +1,362 @@ """Launch flow for the macOS Apple Container backend. -The backend is registered and its host primitives are implemented, but -full launch is intentionally blocked until the sidecar network -enforcement design is finished. Apple Container can publish ports and -create networks, but bot-bottle's Docker topology relies on an agent -container attached only to an internal network while the sidecar bundle -also has egress. The first runnable version must preserve that -no-direct-egress property. +This backend keeps the explicit proxy-env enforcement model for v1: +the agent container is attached only to a host-only Apple Container +network, while the sidecar bundle is attached to a NAT network first +and the host-only network second. The sidecar's host-only IP is +discovered from `container inspect` and stamped into the agent's +HTTP_PROXY / HTTPS_PROXY env vars. """ from __future__ import annotations -from contextlib import contextmanager +import dataclasses +import os +import shutil +import subprocess +from contextlib import ExitStack, contextmanager +from pathlib import Path from typing import Callable, Generator -from ...log import die +from ...bottle_state import egress_state_dir, git_gate_state_dir +from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values +from ...git_gate import revoke_git_gate_provisioned_keys +from ...log import die, info, warn +from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT +from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT +from ..docker.sidecar_bundle import ( + SIDECAR_BUNDLE_DOCKERFILE, + SIDECAR_BUNDLE_IMAGE, +) +from ..docker.egress import egress_tls_init +from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH +from . import util as container_mod from .bottle import MacosContainerBottle from .bottle_plan import MacosContainerBottlePlan +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) +_SIDECAR_SLEEP_SECONDS = "2147483647" + + +def internal_network_name(slug: str) -> str: + return f"bot-bottle-net-{slug}" + + +def egress_network_name(slug: str) -> str: + return f"bot-bottle-egress-{slug}" + + +def sidecar_container_name(slug: str) -> str: + return f"bot-bottle-sidecars-{slug}" + + @contextmanager def launch( plan: MacosContainerBottlePlan, *, provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None], ) -> Generator[MacosContainerBottle, None, None]: - del provision - die( - "macos-container backend launch is not enabled yet: " - "the backend primitives are present, but sidecar network " - "enforcement still needs implementation." + """Build, run, provision, and yield an Apple Container bottle.""" + stack = ExitStack() + bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name) + git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) + + def teardown() -> None: + teardown_exc: BaseException | None = None + try: + stack.close() + except BaseException as exc: # noqa: W0718 - teardown must continue + teardown_exc = exc + warn(f"macos-container teardown failed: {exc!r}") + revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) + if teardown_exc is not None: + raise teardown_exc + + try: + _validate_supported_plan(plan) + plan = _mint_certs(plan) + _build_images(plan) + + internal_network = internal_network_name(plan.slug) + egress_network = egress_network_name(plan.slug) + _create_networks(internal_network, egress_network, stack) + + sidecar_name = sidecar_container_name(plan.slug) + container_mod.force_remove_container(sidecar_name) + _start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network) + stack.callback(container_mod.force_remove_container, sidecar_name) + + sidecar_ip = container_mod.container_ipv4_on_network( + sidecar_name, internal_network, + ) + plan = _stamp_agent_urls(plan, sidecar_ip) + + container_mod.force_remove_container(plan.container_name) + _start_agent(plan, internal_network, sidecar_ip) + stack.callback(container_mod.force_remove_container, plan.container_name) + + bottle = MacosContainerBottle( + plan.container_name, + teardown, + None, + agent_command=plan.agent_command, + agent_prompt_mode=plan.agent_prompt_mode, + agent_provider_template=plan.agent_provider_template, + terminal_title=plan.spec.label or plan.spec.agent_name, + terminal_color=plan.spec.color, + agent_workdir=plan.workspace_plan.workdir, + ) + bottle.prompt_path = provision(plan, bottle) + + yield bottle + finally: + teardown() + + +def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: + egress_ca_host, egress_ca_cert_only = egress_tls_init( + egress_state_dir(plan.slug), ) - yield # pragma: no cover + egress_plan = dataclasses.replace( + plan.egress_plan, + mitmproxy_ca_host_path=egress_ca_host, + mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + ) + return dataclasses.replace(plan, egress_plan=egress_plan) + + +def _validate_supported_plan(plan: MacosContainerBottlePlan) -> None: + if plan.git_gate_plan.upstreams: + die( + "macos-container backend launch does not support bottle.git yet: " + "Apple Container cannot bind-mount individual SSH key files, " + "and this backend will not mount broad host key directories. " + "Use docker/smolmachines for git-gate bottles until a safe key " + "delivery path lands." + ) + + +def _build_images(plan: MacosContainerBottlePlan) -> None: + container_mod.build_image( + SIDECAR_BUNDLE_IMAGE, + _REPO_DIR, + dockerfile=SIDECAR_BUNDLE_DOCKERFILE, + ) + container_mod.build_image( + plan.image, + _REPO_DIR, + dockerfile=plan.dockerfile_path, + ) + + +def _create_networks( + internal_network: str, + egress_network: str, + stack: ExitStack, +) -> None: + container_mod.create_network(internal_network, internal=True) + stack.callback(container_mod.remove_network, internal_network) + container_mod.create_network(egress_network) + stack.callback(container_mod.remove_network, egress_network) + + +def _start_sidecar_bundle( + plan: MacosContainerBottlePlan, + sidecar_name: str, + internal_network: str, + egress_network: str, +) -> None: + argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network) + effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} + token_values = egress_resolve_token_values( + plan.egress_plan.token_env_map, effective_env, + ) + env = {**os.environ, **token_values} + info(f"container run sidecar bundle {sidecar_name}") + result = subprocess.run( + argv, capture_output=True, text=True, env=env, check=False, + ) + if result.returncode != 0: + die( + f"container run for sidecar bundle {sidecar_name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def _start_agent( + plan: MacosContainerBottlePlan, + internal_network: str, + sidecar_ip: str, +) -> None: + argv = _agent_run_argv(plan, internal_network, sidecar_ip) + env = { + **os.environ, + **plan.forwarded_env, + } + info(f"container run agent {plan.container_name}") + result = subprocess.run( + argv, capture_output=True, text=True, env=env, check=False, + ) + if result.returncode != 0: + die( + f"container run for agent {plan.container_name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def _stamp_agent_urls( + plan: MacosContainerBottlePlan, + sidecar_ip: str, +) -> MacosContainerBottlePlan: + proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" + supervise_url = "" + if plan.supervise_plan is not None: + supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/" + return dataclasses.replace( + plan, + agent_proxy_url=proxy_url, + agent_git_gate_url="", + agent_supervise_url=supervise_url, + ) + + +def _sidecar_run_argv( + plan: MacosContainerBottlePlan, + sidecar_name: str, + internal_network: str, + egress_network: str, +) -> list[str]: + argv = [ + "container", "run", + "--name", sidecar_name, + "--detach", + "--rm", + "--network", egress_network, + "--network", internal_network, + "--dns", _sidecar_dns(), + "--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}", + ] + for entry in _sidecar_env_entries(plan): + argv += ["--env", entry] + for host_path, container_path, read_only in _sidecar_mounts(plan): + argv += ["--mount", _mount_spec(host_path, container_path, read_only)] + argv.append(SIDECAR_BUNDLE_IMAGE) + return argv + + +def _agent_run_argv( + plan: MacosContainerBottlePlan, + internal_network: str, + sidecar_ip: str, +) -> list[str]: + argv = [ + "container", "run", + "--name", plan.container_name, + "--detach", + "--rm", + "--network", internal_network, + ] + for entry in _agent_env_entries(plan, sidecar_ip): + argv += ["--env", entry] + argv += [plan.image, "sleep", _SIDECAR_SLEEP_SECONDS] + return argv + + +def _sidecar_dns() -> str: + return container_mod.dns_server() + + +def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: + daemons = ["egress"] + if plan.supervise_plan is not None: + daemons.append("supervise") + return tuple(daemons) + + +def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]: + env: list[str] = [] + if plan.egress_plan.routes: + env.extend(sorted(plan.egress_plan.token_env_map.keys())) + if plan.supervise_plan is not None: + env += [ + f"SUPERVISE_BOTTLE_SLUG={plan.slug}", + f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", + f"SUPERVISE_PORT={SUPERVISE_PORT}", + ] + return tuple(env) + + +def _sidecar_mounts( + plan: MacosContainerBottlePlan, +) -> tuple[tuple[str, str, bool], ...]: + mounts: list[tuple[str, str, bool]] = [] + + ep = plan.egress_plan + mounts.append(( + str(ep.mitmproxy_ca_host_path.parent), + str(Path(EGRESS_CA_IN_CONTAINER).parent), + False, + )) + if ep.routes: + mounts.append(( + str(_stage_routes_dir(plan)), + str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), + True, + )) + + sp = plan.supervise_plan + if sp is not None: + mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False)) + + return tuple(mounts) + + +def _stage_routes_dir(plan: MacosContainerBottlePlan) -> Path: + routes_dir = plan.stage_dir / "macos-container-egress" + routes_dir.mkdir(parents=True, exist_ok=True) + shutil.copyfile( + plan.egress_plan.routes_path, + routes_dir / Path(EGRESS_ROUTES_IN_CONTAINER).name, + ) + return routes_dir + + +def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str: + spec = f"type=bind,source={host_path},target={container_path}" + if read_only: + spec += ",readonly" + return spec + + +def _agent_env_entries( + plan: MacosContainerBottlePlan, + sidecar_ip: str, +) -> tuple[str, ...]: + proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" + no_proxy = _agent_no_proxy(plan, sidecar_ip) + env = [ + f"HTTPS_PROXY={proxy_url}", + f"HTTP_PROXY={proxy_url}", + f"https_proxy={proxy_url}", + f"http_proxy={proxy_url}", + f"NO_PROXY={no_proxy}", + f"no_proxy={no_proxy}", + f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", + f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", + f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", + ] + if plan.agent_git_gate_url: + env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}") + if plan.agent_supervise_url: + env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}") + for name, value in sorted(plan.agent_provision.guest_env.items()): + env.append(f"{name}={value}") + for name in sorted(plan.forwarded_env.keys()): + env.append(name) + return tuple(env) + + +def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str: + hosts = ["localhost", "127.0.0.1", sidecar_ip] + return ",".join(hosts) diff --git a/bot_bottle/backend/macos_container/util.py b/bot_bottle/backend/macos_container/util.py index 775fbe2..5ee92fe 100644 --- a/bot_bottle/backend/macos_container/util.py +++ b/bot_bottle/backend/macos_container/util.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +import os +import ipaddress import platform import shutil import subprocess @@ -11,6 +14,7 @@ from ...log import die, info _CONTAINER = "container" +_DEFAULT_DNS = "1.1.1.1" def is_macos() -> bool: @@ -32,19 +36,157 @@ def require_container() -> None: die("container not found") +def dns_server() -> str: + override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip() + if override: + return override + return _host_ipv4_dns() or _DEFAULT_DNS + + def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: """Build an OCI image with Apple's BuildKit-backed `container build`.""" info( f"building image {ref} from {context} with Apple Container " "(layer cache keeps repeat builds fast)" ) - args = [_CONTAINER, "build", "-t", ref] + _ensure_builder_dns() + args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()] if dockerfile: args.extend(["-f", dockerfile]) args.append(context) subprocess.run(args, check=True) +def _ensure_builder_dns() -> None: + dns = dns_server() + status = _builder_status() + override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip() + if _builder_running(status) and _builder_resolves_build_hosts(): + if override and not _builder_has_dns(status, dns): + _restart_builder_with_dns(dns) + return + _restart_builder_with_dns(dns) + + +def _restart_builder_with_dns(dns: str) -> None: + subprocess.run( + [_CONTAINER, "builder", "stop"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + subprocess.run( + [_CONTAINER, "builder", "start", "--dns", dns], + check=True, + ) + + +def _host_ipv4_dns() -> str: + if not is_macos(): + return "" + result = subprocess.run( + ["scutil", "--dns"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return "" + blocks: list[list[str]] = [] + current: list[str] = [] + for line in result.stdout.splitlines(): + if line.startswith("resolver #") and current: + blocks.append(current) + current = [] + current.append(line) + if current: + blocks.append(current) + for direct_only in (True, False): + for block in blocks: + text = "\n".join(block) + if direct_only and "Directly Reachable Address" not in text: + continue + for line in block: + if "nameserver[" not in line or ":" not in line: + continue + candidate = line.split(":", 1)[1].strip() + if _usable_ipv4(candidate): + return candidate + return "" + + +def _usable_ipv4(value: str) -> bool: + try: + address = ipaddress.ip_address(value) + except ValueError: + return False + return ( + address.version == 4 + and not address.is_loopback + and not address.is_link_local + and not address.is_multicast + and not address.is_unspecified + ) + + +def _builder_status() -> list[dict[str, object]]: + result = subprocess.run( + [_CONTAINER, "builder", "status", "--format", "json"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + try: + data = json.loads(result.stdout or "[]") + except json.JSONDecodeError: + return [] + if isinstance(data, list): + return [entry for entry in data if isinstance(entry, dict)] + if isinstance(data, dict): + return [data] + return [] + + +def _builder_running(status: list[dict[str, object]]) -> bool: + for entry in status: + entry_status = entry.get("status") + if isinstance(entry_status, dict) and entry_status.get("state") == "running": + return True + return False + + +def _builder_dns_nameservers(status: list[dict[str, object]]) -> list[str]: + out: list[str] = [] + for entry in status: + config = entry.get("configuration") + config_dns = config.get("dns") if isinstance(config, dict) else None + nameservers = ( + config_dns.get("nameservers") + if isinstance(config_dns, dict) + else None + ) + if not isinstance(nameservers, list): + continue + out.extend(name for name in nameservers if isinstance(name, str)) + return out + + +def _builder_has_dns(status: list[dict[str, object]], dns: str) -> bool: + return dns in _builder_dns_nameservers(status) + + +def _builder_resolves_build_hosts() -> bool: + result = subprocess.run( + [_CONTAINER, "exec", "buildkit", "getent", "hosts", "deb.debian.org"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + def image_exists(ref: str) -> bool: return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0 @@ -71,6 +213,81 @@ def force_remove_container(name: str) -> None: ) +def create_network(name: str, *, internal: bool = False) -> None: + args = [ + _CONTAINER, "network", "create", + "--label", "bot-bottle.backend=macos-container", + ] + if internal: + args.append("--internal") + args.append(name) + result = subprocess.run( + args, capture_output=True, text=True, check=False, + ) + if result.returncode == 0: + return + if "already exists" in (result.stderr or "").lower(): + return + die( + f"container network create {name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def remove_network(name: str) -> None: + result = subprocess.run( + [_CONTAINER, "network", "delete", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if result.returncode != 0: + return + + +def inspect_container(name: str) -> dict[str, object]: + result = subprocess.run( + [_CONTAINER, "inspect", name], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + die( + f"container inspect {name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + try: + data = json.loads(result.stdout or "[]") + except json.JSONDecodeError as exc: + die(f"container inspect {name} returned malformed JSON: {exc}") + if isinstance(data, list) and data and isinstance(data[0], dict): + return data[0] + if isinstance(data, dict): + return data + die(f"container inspect {name} returned an unexpected shape") + raise AssertionError("unreachable") + + +def container_ipv4_on_network(name: str, network: str) -> str: + data = inspect_container(name) + status = data.get("status") + networks = status.get("networks") if isinstance(status, dict) else None + if not isinstance(networks, list): + die(f"container inspect {name} did not include status.networks") + for entry in networks: + if not isinstance(entry, dict): + continue + if entry.get("network") != network: + continue + raw = entry.get("ipv4Address") + if not isinstance(raw, str) or not raw: + die(f"container {name} has no IPv4 address on {network}") + return raw.split("/", 1)[0] + die(f"container {name} is not attached to network {network}") + raise AssertionError("unreachable") + + def image_id(ref: str) -> str: """Return the image digest/ID from `container image inspect`. diff --git a/docs/prds/prd-new-macos-container-backend.md b/docs/prds/prd-new-macos-container-backend.md index a4f9745..570595c 100644 --- a/docs/prds/prd-new-macos-container-backend.md +++ b/docs/prds/prd-new-macos-container-backend.md @@ -9,10 +9,10 @@ Add an experimental `macos-container` backend that integrates Apple's `container` CLI as a host runtime on macOS. The first shipped slice -registers the backend, implements the reusable host primitives +registers the backend and implements reusable host primitives (`build`, `exec`, `cp`, image inspection, cleanup, active -enumeration), and blocks full launch behind an explicit network -enforcement guard. This creates a real integration point without +enumeration). Follow-up slices make launch runnable with the proven +two-network sidecar topology and add real-runtime coverage, without weakening bot-bottle's sidecar egress model. ## Problem @@ -49,10 +49,15 @@ path around the egress sidecar. - The backend has tested wrappers for Apple Container image build, image inspection, container `exec`, container `cp`, cleanup, and active-agent enumeration. -- Full launch fails loudly with an operator-facing message until the - sidecar network enforcement design is implemented. -- The PRD records the remaining launch work so the next PR can make the - backend runnable without revisiting registration or wrapper plumbing. +- Full launch uses a host-only internal network for the agent and a + separate NAT egress network for the sidecar bundle. +- The agent container does not attach to the egress network. It reaches + allowed outbound hosts through HTTP(S)_PROXY pointing at the + sidecar's internal-network IP. +- `bottle.git` / git-gate bottles fail loudly on this backend until a + safe Apple Container key-delivery path exists. +- Real-runtime integration coverage is present and guarded by macOS and + Apple Container availability. ## Non-goals @@ -101,25 +106,38 @@ The bottle handle mirrors `DockerBottle`: it builds a host argv for foreground agent execution, pipes shell snippets through stdin for `Bottle.exec`, and exposes `cp_in` for provisioning. -### Launch guard +### Launch topology -`launch()` is intentionally not enabled in the first slice. It exits -with a fatal message explaining that sidecar network enforcement still -needs implementation. +`launch()` uses Apple Container's two-network topology: -This is deliberate. A runnable backend that places the agent on a -normal outbound network while relying on environment variables for -proxying would violate bot-bottle's egress model. The runnable version -must prove one of these shapes: +- create a host-only internal network for the bottle; +- create a normal NAT egress network for the sidecar bundle; +- start the sidecar bundle attached to the egress network first and the + internal network second; +- discover the sidecar's internal-network IPv4 address from + `container inspect`; +- start the agent attached only to the internal network, with + HTTP_PROXY / HTTPS_PROXY / lowercase proxy vars pointing at the + sidecar IP and egress port. -- Apple Container supports the equivalent of Docker's two-network - sidecar topology: agent on an internal-only network, sidecar on both - internal and egress networks. -- The sidecar bundle runs as a separate VM/container with published - loopback ports, and the agent runtime can be constrained to only - reach that per-bottle loopback alias. -- Apple Container init/network hooks can enforce the egress sidecar as - the only outbound path before the agent process starts. +This keeps the agent off the outbound network while preserving the +proxy-env contract that existing agent tooling already honors. The +integration smoke also removes the proxy env in-guest and confirms +direct egress fails. + +### Deferred git-gate support + +Apple Container currently rejects single-file bind mounts, and +`container cp` into a stopped container is not available. Starting the +container earlier would allow `container cp` into a running container, +but it would also mean delivering SSH private key material into a live +sidecar before the git-gate daemon is ready to own it. Mounting broad +host SSH directories is not acceptable. + +For this PRD, `bottle.git` / git-gate support is explicitly deferred on +the `macos-container` backend. Bottles with git-gate upstreams fail +loudly and should use `docker` or `smolmachines` until a narrower key +delivery design lands. ## Implementation chunks @@ -147,8 +165,19 @@ must prove one of these shapes: - Unit tests cover `MacosContainerBottle` command construction and stdin-based shell execution. - Unit tests cover cleanup and active enumeration parsing. -- Future integration tests must run on a host with Apple Container - installed and should verify egress cannot bypass the sidecar. +- Unit tests cover launch argv/env construction, sidecar mount + staging, sidecar IP parsing, and git-gate rejection. +- Integration tests run on macOS hosts with Apple Container installed + and verify that egress cannot bypass the sidecar. They also preflight + Apple Container BuildKit DNS because image builds must resolve + package mirrors before a launch smoke can be meaningful. The backend + probes the running builder before image builds and leaves it alone + when its current resolver works. If the probe fails, or if the + operator explicitly sets `BOT_BOTTLE_MACOS_CONTAINER_DNS`, the backend + restarts the Apple Container builder with the configured DNS server. + Without an explicit override, that server is discovered from the + host's directly reachable IPv4 resolver before falling back to a + public resolver. ## References diff --git a/tests/integration/test_macos_container_launch.py b/tests/integration/test_macos_container_launch.py new file mode 100644 index 0000000..9ce384f --- /dev/null +++ b/tests/integration/test_macos_container_launch.py @@ -0,0 +1,239 @@ +"""Integration: macOS Container launch topology. + +End-to-end against Apple's real `container` runtime. The smoke launches +a bottle with the experimental macOS Container backend and verifies the +properties that make the explicit-proxy launch acceptable: + + - the agent can exec commands after provisioning; + - HTTP(S)_PROXY points at the sidecar's internal-network IP; + - allowlisted HTTPS reaches the egress sidecar; + - direct egress with proxy env removed fails from the internal-only + agent network; + - non-allowlisted proxy traffic is blocked. + +Skipped under Gitea Actions and on hosts without Apple's `container`. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +from bot_bottle.backend import BottleSpec, get_bottle_backend +from bot_bottle.backend.macos_container.util import ( + dns_server as _container_dns_server, + is_available as _container_available, +) +from bot_bottle.manifest import Manifest + + +_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief." + + +def _minimal_agent_dockerfile(path: Path) -> None: + path.write_text( + "\n".join(( + "FROM node:22-slim", + "RUN apt-get update \\", + " && apt-get install -y --no-install-recommends \\", + " ca-certificates curl git \\", + " && rm -rf /var/lib/apt/lists/*", + "USER node", + "WORKDIR /home/node", + "CMD [\"sleep\", \"infinity\"]", + "", + )), + encoding="utf-8", + ) + + +def _minimal_manifest(dockerfile: Path) -> Manifest: + return Manifest.from_json_obj({ + "bottles": { + "dev": { + "agent_provider": { + "template": "pi", + "dockerfile": str(dockerfile), + "settings": { + "provider": "example", + "base_url": "https://example.com/v1", + "models": ["smoke"], + }, + }, + "egress": { + "routes": [ + {"host": "example.com"}, + ], + }, + }, + }, + "agents": { + "demo": { + "skills": [], + "prompt": _AGENT_PROMPT, + "bottle": "dev", + }, + }, + }) + + +def _buildkit_dns_available() -> bool: + if platform.system() != "Darwin" or not _container_available(): + return False + stage = Path(tempfile.mkdtemp(prefix="cb-container-buildkit-dns.")) + image = "bot-bottle-buildkit-dns-check:latest" + try: + dockerfile = stage / "Dockerfile" + dockerfile.write_text( + "FROM debian:bookworm-slim\n" + "RUN getent hosts deb.debian.org\n", + encoding="utf-8", + ) + result = subprocess.run( + [ + "container", "build", + "--dns", _container_dns_server(), + "-t", image, + "-f", str(dockerfile), + str(stage), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + finally: + subprocess.run( + ["container", "image", "delete", image], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + shutil.rmtree(stage, ignore_errors=True) + + +@unittest.skipIf( + os.environ.get("GITEA_ACTIONS") == "true", + "skipped under act_runner: cannot host Apple Container VMs", +) +@unittest.skipUnless( + platform.system() == "Darwin", + "Apple Container is macOS-only", +) +@unittest.skipUnless( + _container_available(), + "Apple Container not on PATH; install from " + "https://github.com/apple/container/releases", +) +@unittest.skipUnless( + _buildkit_dns_available(), + "Apple Container BuildKit cannot resolve deb.debian.org on this host", +) +class TestMacosContainerLaunch(unittest.TestCase): + """Launch once and reuse the bottle across probes.""" + + @classmethod + def setUpClass(cls) -> None: + cls.stage = Path(tempfile.mkdtemp(prefix="cb-macos-container-launch.")) + cls._launch = None + cls.bottle = None + dockerfile = cls.stage / "Dockerfile.agent-smoke" + _minimal_agent_dockerfile(dockerfile) + os.environ["BOT_BOTTLE_BACKEND"] = "macos-container" + try: + backend = get_bottle_backend() + spec = BottleSpec( + manifest=_minimal_manifest(dockerfile), + agent_name="demo", + copy_cwd=False, + user_cwd=str(cls.stage), + ) + cls.plan = backend.prepare(spec, stage_dir=cls.stage) + cls._launch = backend.launch(cls.plan) + cls.bottle = cls._launch.__enter__() + except BaseException: + if cls._launch is not None: + cls._launch.__exit__(None, None, None) + shutil.rmtree(cls.stage, ignore_errors=True) + os.environ.pop("BOT_BOTTLE_BACKEND", None) + raise + + @classmethod + def tearDownClass(cls) -> None: + try: + if cls._launch is not None: + cls._launch.__exit__(None, None, None) + finally: + shutil.rmtree(cls.stage, ignore_errors=True) + os.environ.pop("BOT_BOTTLE_BACKEND", None) + + def test_smoke_exec_echo(self): + r = self.bottle.exec( # type: ignore[union-attr] + "echo hello-from-macos-container" + ) + self.assertEqual(0, r.returncode, msg=r.stderr) + self.assertIn("hello-from-macos-container", r.stdout) + + def test_proxy_env_points_at_sidecar_internal_ip(self): + r = self.bottle.exec( # type: ignore[union-attr] + "printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\" " + "\"$NO_PROXY\" \"$NODE_EXTRA_CA_CERTS\"" + ) + self.assertEqual(0, r.returncode, msg=r.stderr) + values = [line.strip() for line in r.stdout.splitlines()] + self.assertEqual(4, len(values), values) + self.assertEqual(values[0], values[1], values) + self.assertRegex(values[0], r"^http://[0-9.]+:9099$") + self.assertNotIn("127.0.0.1", values[0]) + sidecar_host = values[0].removeprefix("http://").removesuffix(":9099") + self.assertIn(sidecar_host, values[2]) + self.assertEqual( + "/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", + values[3], + ) + + def test_allowlisted_https_reaches_egress_proxy(self): + r = self.bottle.exec( # type: ignore[union-attr] + "curl -fsS --max-time 20 https://example.com >/dev/null && echo OK" + ) + self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout) + self.assertIn("OK", r.stdout) + + def test_direct_egress_bypass_without_proxy_fails(self): + r = self.bottle.exec( # type: ignore[union-attr] + "env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy " + "curl -s --show-error --max-time 5 https://example.com 2>&1 || true" + ) + self.assertTrue( + "refused" in r.stdout.lower() + or "timed out" in r.stdout.lower() + or "unreachable" in r.stdout.lower() + or "failed" in r.stdout.lower() + or "could not resolve" in r.stdout.lower() + or "connection reset" in r.stdout.lower(), + f"expected direct egress to fail; got: {r.stdout!r}", + ) + + def test_non_allowlisted_host_fails_through_proxy(self): + r = self.bottle.exec( # type: ignore[union-attr] + "curl -s --show-error --max-time 10 https://iana.org 2>&1 || true" + ) + self.assertTrue( + "403" in r.stdout + or "502" in r.stdout + or "blocked" in r.stdout.lower() + or "not allowed" in r.stdout.lower() + or "not in the bottle's egress.routes allowlist" in r.stdout.lower() + or "forbidden" in r.stdout.lower() + or "failed" in r.stdout.lower(), + f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macos_container_cleanup.py b/tests/unit/test_macos_container_cleanup.py index d1502f2..fc2d980 100644 --- a/tests/unit/test_macos_container_cleanup.py +++ b/tests/unit/test_macos_container_cleanup.py @@ -45,7 +45,10 @@ class TestMacosContainerCleanup(unittest.TestCase): class TestMacosContainerEnumerate(unittest.TestCase): def test_enumerate_active_reads_metadata(self): completed = enum_mod.subprocess.CompletedProcess( - args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="", + args=[], + returncode=0, + stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n", + stderr="", ) class _Metadata: diff --git a/tests/unit/test_macos_container_launch.py b/tests/unit/test_macos_container_launch.py new file mode 100644 index 0000000..27e41b6 --- /dev/null +++ b/tests/unit/test_macos_container_launch.py @@ -0,0 +1,163 @@ +"""Unit: Apple Container launch argv construction.""" + +from __future__ import annotations + +import unittest +import tempfile +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import patch + +from bot_bottle.backend.macos_container import launch +from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan + + +def _plan( + *, + stage_dir: Path, + git: bool = False, + supervise: bool = False, + agent_git_gate_url: str = "", + agent_supervise_url: str = "", +) -> MacosContainerBottlePlan: + routes_path = stage_dir / "source-routes.yaml" + routes_path.write_text("routes: []\n", encoding="utf-8") + ca_dir = stage_dir / "egress-ca" + ca_dir.mkdir(exist_ok=True) + ca_path = ca_dir / "mitmproxy-ca.pem" + ca_path.write_text("ca\n", encoding="utf-8") + egress_plan = SimpleNamespace( + mitmproxy_ca_host_path=ca_path, + routes_path=routes_path, + routes=("route",), + token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"}, + ) + if git: + upstream = SimpleNamespace( + name="origin", + identity_file="/host/key", + known_hosts_file=Path("/host/known_hosts"), + ) + git_gate_plan = SimpleNamespace( + upstreams=(upstream,), + entrypoint_script=Path("/state/git/entrypoint.sh"), + hook_script=Path("/state/git/pre-receive"), + access_hook_script=Path("/state/git/access.sh"), + ) + else: + git_gate_plan = SimpleNamespace(upstreams=()) + supervise_plan = ( + SimpleNamespace(queue_dir=Path("/state/supervise/queue")) + if supervise else None + ) + agent_provision = SimpleNamespace( + guest_env={"LITERAL": "value"}, + provisioned_env={"CODEX_HOME": "/run/codex-home"}, + ) + return cast(MacosContainerBottlePlan, SimpleNamespace( + stage_dir=stage_dir, + slug="dev-abc", + container_name="bot-bottle-dev-abc", + image="bot-bottle-agent:latest", + forwarded_env={"OAUTH_TOKEN": "host-value"}, + egress_plan=egress_plan, + git_gate_plan=git_gate_plan, + supervise_plan=supervise_plan, + agent_provision=agent_provision, + agent_git_gate_url=agent_git_gate_url, + agent_supervise_url=agent_supervise_url, + )) + + +class TestMacosContainerLaunchArgv(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.stage_dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self): + plan = _plan(stage_dir=self.stage_dir, supervise=True) + with patch.object(launch.os, "environ", { + "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", + }): + argv = launch._sidecar_run_argv( + plan, + "bot-bottle-sidecars-dev-abc", + "bot-bottle-net-dev-abc", + "bot-bottle-egress-dev-abc", + ) + self.assertEqual( + [ + "--network", "bot-bottle-egress-dev-abc", + "--network", "bot-bottle-net-dev-abc", + ], + argv[argv.index("--network"):argv.index("--dns")], + ) + self.assertIn("--dns", argv) + self.assertEqual("9.9.9.9", argv[argv.index("--dns") + 1]) + self.assertIn( + "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise", + argv, + ) + self.assertIn("EGRESS_TOKEN_0", argv) + self.assertIn( + f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy", + argv, + ) + routes_dir = self.stage_dir / "macos-container-egress" + self.assertIn( + f"type=bind,source={routes_dir},target=/etc/egress,readonly", + argv, + ) + self.assertEqual( + "routes: []\n", + (routes_dir / "routes.yaml").read_text(encoding="utf-8"), + ) + self.assertIn( + "type=bind,source=/state/supervise/queue,target=/run/supervise/queue", + argv, + ) + + def test_agent_env_points_proxy_at_sidecar_ip(self): + plan = _plan( + stage_dir=self.stage_dir, + agent_git_gate_url="http://192.168.128.2:9420", + agent_supervise_url="http://192.168.128.2:9100/", + ) + env = launch._agent_env_entries(plan, "192.168.128.2") + self.assertIn("HTTPS_PROXY=http://192.168.128.2:9099", env) + self.assertIn("HTTP_PROXY=http://192.168.128.2:9099", env) + self.assertIn("https_proxy=http://192.168.128.2:9099", env) + self.assertIn("http_proxy=http://192.168.128.2:9099", env) + self.assertIn("NO_PROXY=localhost,127.0.0.1,192.168.128.2", env) + self.assertIn("NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", env) + self.assertIn("SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", env) + self.assertIn("REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt", env) + self.assertIn("GIT_GATE_URL=http://192.168.128.2:9420", env) + self.assertIn("MCP_SUPERVISE_URL=http://192.168.128.2:9100/", env) + self.assertIn("LITERAL=value", env) + self.assertIn("OAUTH_TOKEN", env) + self.assertNotIn("CODEX_HOME", env) + + def test_agent_run_uses_internal_network_only(self): + plan = _plan(stage_dir=self.stage_dir) + argv = launch._agent_run_argv( + plan, "bot-bottle-net-dev-abc", "192.168.128.2", + ) + self.assertIn("--network", argv) + self.assertEqual("bot-bottle-net-dev-abc", argv[argv.index("--network") + 1]) + self.assertNotIn("bot-bottle-egress-dev-abc", argv) + self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:]) + + def test_git_gate_is_blocked_until_safe_key_delivery_exists(self): + plan = _plan(stage_dir=self.stage_dir, git=True) + with patch.object(launch, "die", side_effect=SystemExit("die")): + with self.assertRaises(SystemExit): + launch._validate_supported_plan(plan) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macos_container_util.py b/tests/unit/test_macos_container_util.py index db58378..9789a29 100644 --- a/tests/unit/test_macos_container_util.py +++ b/tests/unit/test_macos_container_util.py @@ -28,17 +28,137 @@ class TestMacosContainerAvailability(unittest.TestCase): class TestMacosContainerCommands(unittest.TestCase): + def test_dns_server_prefers_direct_host_ipv4_resolver(self): + scutil = util.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=""" +resolver #1 + nameserver[0] : 100.100.100.100 + reach : 0x00000003 (Reachable,Transient Connection) + +resolver #2 + nameserver[0] : 2600:4041:5c43:b900::1 + nameserver[1] : 192.168.1.1 + reach : 0x00020002 (Reachable,Directly Reachable Address) +""", + stderr="", + ) + with patch.object(util.os, "environ", {}), \ + patch.object(util.platform, "system", return_value="Darwin"), \ + patch.object(util.subprocess, "run", return_value=scutil): + self.assertEqual("192.168.1.1", util.dns_server()) + def test_build_image(self): - with patch.object(util.subprocess, "run") as run: + status = util.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=( + '[{"status":{"state":"running"},' + '"configuration":{"dns":{"nameservers":["9.9.9.9"]}}}]' + ), + stderr="", + ) + with patch.object(util.subprocess, "run", return_value=status) as run, \ + patch.object(util.os, "environ", { + "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", + }): util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile") self.assertEqual( [ "container", "build", "-t", "bot-bottle-agent:latest", - "-f", "/repo/Dockerfile", "/repo", + "--dns", "9.9.9.9", "-f", "/repo/Dockerfile", "/repo", ], - run.call_args.args[0], + run.call_args_list[-1].args[0], + ) + self.assertTrue(run.call_args_list[-1].kwargs["check"]) + + def test_build_image_restarts_builder_when_dns_mismatches(self): + status = util.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=( + '[{"status":{"state":"running"},' + '"configuration":{"dns":{"nameservers":[]}}}]' + ), + stderr="", + ) + with patch.object(util.subprocess, "run", return_value=status) as run, \ + patch.object(util.os, "environ", { + "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", + }): + util.build_image("bot-bottle-agent:latest", "/repo") + calls = [c.args[0] for c in run.call_args_list] + self.assertIn(["container", "builder", "stop"], calls) + self.assertIn( + ["container", "builder", "start", "--dns", "9.9.9.9"], + calls, + ) + self.assertEqual( + [ + "container", "build", "-t", "bot-bottle-agent:latest", + "--dns", "9.9.9.9", "/repo", + ], + calls[-1], + ) + + def test_build_image_leaves_working_builder_with_different_dns_alone(self): + status = util.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=( + '[{"status":{"state":"running"},' + '"configuration":{"dns":{"nameservers":["8.8.8.8"]}}}]' + ), + stderr="", + ) + probe = util.subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="", + ) + build = util.subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="", + ) + with patch.object(util, "dns_server", return_value="192.168.1.1"), \ + patch.object(util.os, "environ", {}), \ + patch.object(util.subprocess, "run", side_effect=[status, probe, build]) as run: + util.build_image("bot-bottle-agent:latest", "/repo") + calls = [c.args[0] for c in run.call_args_list] + self.assertNotIn(["container", "builder", "stop"], calls) + self.assertNotIn( + ["container", "builder", "start", "--dns", "192.168.1.1"], + calls, + ) + + def test_build_image_restarts_builder_when_dns_probe_fails(self): + status = util.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=( + '[{"status":{"state":"running"},' + '"configuration":{"dns":{"nameservers":["8.8.8.8"]}}}]' + ), + stderr="", + ) + failed_probe = util.subprocess.CompletedProcess( + args=[], returncode=2, stdout="", stderr="", + ) + ok = util.subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="", + ) + with patch.object(util, "dns_server", return_value="192.168.1.1"), \ + patch.object(util.os, "environ", {}), \ + patch.object( + util.subprocess, + "run", + side_effect=[status, failed_probe, ok, ok, ok], + ) as run: + util.build_image("bot-bottle-agent:latest", "/repo") + calls = [c.args[0] for c in run.call_args_list] + self.assertIn(["container", "builder", "stop"], calls) + self.assertIn( + ["container", "builder", "start", "--dns", "192.168.1.1"], + calls, ) - self.assertTrue(run.call_args.kwargs["check"]) def test_container_exists_parses_quiet_list(self): completed = util.subprocess.CompletedProcess( @@ -55,6 +175,29 @@ class TestMacosContainerCommands(unittest.TestCase): with patch.object(util.subprocess, "run", return_value=completed): self.assertEqual("sha256:abc", util.image_id("demo:latest")) + def test_container_ipv4_on_network_reads_inspect_json(self): + payload = """[{ + "status": { + "networks": [ + { + "network": "bot-bottle-net-demo", + "ipv4Address": "192.168.128.2/24" + } + ] + } + }]""" + completed = util.subprocess.CompletedProcess( + args=[], returncode=0, stdout=payload, stderr="", + ) + with patch.object(util.subprocess, "run", return_value=completed): + self.assertEqual( + "192.168.128.2", + util.container_ipv4_on_network( + "bot-bottle-sidecars-demo", + "bot-bottle-net-demo", + ), + ) + if __name__ == "__main__": unittest.main()