diff --git a/AGENTS.md b/AGENTS.md index a0b74be..ff7ca05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,13 @@ with a curated set of skills and env vars. The point is to run agents with broad permissions inside a sandbox, so a misbehaving agent cannot reach the host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates the runtime lifecycle and the copying of skills and env vars into it. -The default backend is smolmachines on macOS: agents run in a libkrun -micro-VM, while the sidecar bundle still uses Docker. The legacy Docker -backend remains available with `BOT_BOTTLE_BACKEND=docker` or -`--backend=docker`. +The default backend on compatible macOS hosts is macos-container: +agents and sidecar bundles run through Apple's `container` CLI without +requiring Docker. The smolmachines backend remains available with +`BOT_BOTTLE_BACKEND=smolmachines` or `--backend=smolmachines`; agents +run in a libkrun micro-VM, while the sidecar bundle still uses Docker. +The legacy Docker backend remains available with `BOT_BOTTLE_BACKEND=docker` +or `--backend=docker`. ## Goals diff --git a/README.md b/README.md index 223d195..083a649 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,15 @@ - **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other. - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. -- **Smolmachines backend (macOS default)** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. -- **Legacy Docker backend** — still available for examples, CI, and hosts without smolvm via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. +- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network. +- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. +- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. ## Architecture -On the default smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS. +On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists. + +On the smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS. On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box. @@ -67,9 +70,9 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi ## Quickstart -Requires Docker on the host for the sidecar bundle, smolvm on macOS for the default backend, and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. +On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. -Use `BOT_BOTTLE_BACKEND=docker ./cli.py start ` on hosts where smolvm is not installed. +Use `BOT_BOTTLE_BACKEND=docker ./cli.py start ` on hosts where Apple Container is not installed and Docker is the desired backend. ```sh ./cli.py start # builds the image on first run, drops you into claude diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index aea3343..e56621b 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -24,9 +24,10 @@ backend exposes five methods: enough metadata for callers (CLI `list active`, dashboard agents pane) to render a row. -Selection is driven by `--backend` on `start` or -BOT_BOTTLE_BACKEND (env var; default "smolmachines"). Per PRD 0003 the -manifest does not carry a backend field; the host picks. +Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND +(env var). When neither is set, compatible macOS hosts default to +`macos-container`; other hosts default to `smolmachines`. Per PRD 0003 +the manifest does not carry a backend field; the host picks. """ from __future__ import annotations @@ -190,7 +191,7 @@ class ActiveAgent: of sidecar daemons currently up for this bottle (`egress`, `git-gate`, `supervise`); the dashboard uses it to gate edit verbs. `backend_name` is the matching key in - `_BACKENDS` (`docker` / `smolmachines`) — used by the active- + `_BACKENDS` (`docker` / `smolmachines` / `macos-container`) — used by the active- list rendering to disambiguate and by the dashboard's re-attach path.""" @@ -530,6 +531,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): # each backend module can pull BottleSpec / BottlePlan / BottleBackend # via `from . import ...` without hitting a partially-initialized module. from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position +from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position @@ -539,6 +541,7 @@ from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: dis # unparameterized methods (prepare → plan → launch(plan), cleanup, etc.). _BACKENDS: dict[str, BottleBackend[Any, Any]] = { "docker": DockerBottleBackend(), + "macos-container": MacosContainerBottleBackend(), "smolmachines": SmolmachinesBottleBackend(), } @@ -551,17 +554,24 @@ def get_bottle_backend( `name` precedence: 1. explicit arg (CLI `--backend=` passes through here) 2. BOT_BOTTLE_BACKEND env var - 3. default `smolmachines` + 3. `macos-container` on compatible macOS hosts + 4. default `smolmachines` Dies with a pointer at the known backends if the chosen name isn't implemented.""" - resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines" + resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name() if resolved not in _BACKENDS: known = ", ".join(sorted(_BACKENDS)) die(f"unknown backend {resolved!r}; known backends: {known}") return _BACKENDS[resolved] +def _default_backend_name() -> str: + if has_backend("macos-container"): + return "macos-container" + return "smolmachines" + + def known_backend_names() -> tuple[str, ...]: """Sorted tuple of all backend keys in `_BACKENDS`. Used by argparse (`--backend` choices) and the dashboard's backend diff --git a/bot_bottle/backend/macos_container/__init__.py b/bot_bottle/backend/macos_container/__init__.py new file mode 100644 index 0000000..8a7e222 --- /dev/null +++ b/bot_bottle/backend/macos_container/__init__.py @@ -0,0 +1,10 @@ +"""macOS Apple Container backend. + +Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns +the Apple `container` CLI integration; launch remains gated until the +sidecar network enforcement shape is implemented. +""" + +from .backend import MacosContainerBottleBackend + +__all__ = ["MacosContainerBottleBackend"] diff --git a/bot_bottle/backend/macos_container/backend.py b/bot_bottle/backend/macos_container/backend.py new file mode 100644 index 0000000..14a0496 --- /dev/null +++ b/bot_bottle/backend/macos_container/backend.py @@ -0,0 +1,84 @@ +"""MacosContainerBottleBackend — Apple Container implementation.""" + +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path +from typing import Generator, Sequence + +from ...agent_provider import AgentProvisionPlan +from ...egress import EgressPlan +from ...env import ResolvedEnv +from ...git_gate import GitGatePlan +from ...supervise import SupervisePlan +from .. import ActiveAgent, BottleBackend, BottleSpec +from . import cleanup as _cleanup +from . import enumerate as _enumerate +from . import launch as _launch +from . import resolve_plan as _resolve_plan +from . import util as _container +from .bottle import MacosContainerBottle +from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan +from .bottle_plan import MacosContainerBottlePlan + + +class MacosContainerBottleBackend( + BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"] +): + """Apple Container backend. Selected by + `BOT_BOTTLE_BACKEND=macos-container` or + `--backend=macos-container`.""" + + name = "macos-container" + + @classmethod + def is_available(cls) -> bool: + return _container.is_available() + + def _preflight(self) -> None: + _resolve_plan.preflight() + + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: + return _resolve_plan.build_guest_env(resolved_env) + + def _resolve_plan( + self, + spec: BottleSpec, + *, + slug: str, + resolved_env: ResolvedEnv, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + git_gate_plan: GitGatePlan, + supervise_plan: SupervisePlan | None, + stage_dir: Path, + ) -> MacosContainerBottlePlan: + return _resolve_plan.resolve_plan( + spec, + slug=slug, + resolved_env=resolved_env, + agent_provision_plan=agent_provision_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + git_gate_plan=git_gate_plan, + stage_dir=stage_dir, + ) + + @contextmanager + def launch( + self, plan: MacosContainerBottlePlan + ) -> Generator[MacosContainerBottle, None, None]: + with _launch.launch(plan, provision=self.provision) as bottle: + yield bottle + + def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan: + return _cleanup.prepare_cleanup() + + def cleanup(self, plan: MacosContainerBottleCleanupPlan) -> None: + _cleanup.cleanup(plan) + + 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.py b/bot_bottle/backend/macos_container/bottle.py new file mode 100644 index 0000000..6919046 --- /dev/null +++ b/bot_bottle/backend/macos_container/bottle.py @@ -0,0 +1,91 @@ +"""Bottle handle for Apple's `container` CLI.""" + +from __future__ import annotations + +import subprocess +from typing import Callable, cast + +from ...agent_provider import PromptMode, prompt_args +from .. import Bottle, ExecResult +from ..terminal import exec_shell_script + + +class MacosContainerBottle(Bottle): + def __init__( + self, + container: str, + teardown: Callable[[], None], + prompt_path_in_container: str | None, + *, + agent_command: str = "claude", + agent_prompt_mode: PromptMode = "append_file", + agent_provider_template: str = "claude", + terminal_title: str = "", + terminal_color: str = "", + agent_workdir: str = "/home/node", + ): + self.name = container + self._teardown = teardown + self.prompt_path = prompt_path_in_container + self._agent_prompt_mode = agent_prompt_mode + self.agent_command = agent_command + self.terminal_title = terminal_title + self.terminal_color = terminal_color + self.agent_provider_template = agent_provider_template + self.agent_workdir = agent_workdir + self._closed = False + + def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]: + full_argv = list(argv) + full_argv.extend( + prompt_args( + cast(PromptMode, self._agent_prompt_mode), + self.prompt_path, + argv=full_argv, + ) + ) + cmd = ["container", "exec"] + if tty: + cmd.extend(["--interactive", "--tty"]) + if self.agent_workdir and self.agent_workdir != "/home/node": + cmd.extend(["--workdir", self.agent_workdir]) + cmd.extend([self.name, self.agent_command, *full_argv]) + return cmd + + def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: + agent_argv = self.agent_argv(argv, tty=tty) + script = ( + exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) + if tty else None + ) + if script is None: + return subprocess.run(agent_argv, check=False).returncode + return subprocess.run(["sh", "-lc", script], check=False).returncode + + def exec(self, script: str, *, user: str = "node") -> ExecResult: + result = subprocess.run( + ["container", "exec", "--user", user, "--interactive", + self.name, "sh", "-s"], + input=script, + capture_output=True, + text=True, + check=False, + ) + return ExecResult( + returncode=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + ) + + def cp_in(self, host_path: str, container_path: str) -> None: + subprocess.run( + ["container", "cp", host_path, f"{self.name}:{container_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._teardown() diff --git a/bot_bottle/backend/macos_container/bottle_cleanup_plan.py b/bot_bottle/backend/macos_container/bottle_cleanup_plan.py new file mode 100644 index 0000000..679a4f6 --- /dev/null +++ b/bot_bottle/backend/macos_container/bottle_cleanup_plan.py @@ -0,0 +1,27 @@ +"""Cleanup plan for the macOS Apple Container backend.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ...log import info +from .. import BottleCleanupPlan + + +@dataclass(frozen=True) +class MacosContainerBottleCleanupPlan(BottleCleanupPlan): + containers: tuple[str, ...] = () + networks: tuple[str, ...] = () + + def print(self) -> None: + if not self.containers and not self.networks: + info("macos-container cleanup: nothing to remove") + return + for name in self.containers: + info(f"macos-container container: {name}") + for name in self.networks: + info(f"macos-container network: {name}") + + @property + def empty(self) -> bool: + return not self.containers and not self.networks diff --git a/bot_bottle/backend/macos_container/bottle_plan.py b/bot_bottle/backend/macos_container/bottle_plan.py new file mode 100644 index 0000000..6d10a60 --- /dev/null +++ b/bot_bottle/backend/macos_container/bottle_plan.py @@ -0,0 +1,58 @@ +"""Plan type for the macOS Apple Container backend.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from ...agent_provider import PromptMode +from .. import BottlePlan + + +@dataclass(frozen=True) +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: + return self.agent_provision.instance_name + + @property + def image(self) -> str: + return self.agent_provision.image + + @property + def dockerfile_path(self) -> str: + return self.agent_provision.dockerfile + + @property + def prompt_file(self) -> Path: + return self.agent_provision.prompt_file + + @property + def agent_command(self) -> str: + return self.agent_provision.command + + @property + def agent_prompt_mode(self) -> PromptMode: + return self.agent_provision.prompt_mode + + @property + def agent_provider_template(self) -> str: + return self.agent_provision.template + + @property + def git_gate_insteadof_host(self) -> str: + if self.agent_git_gate_url.startswith("http://"): + return self.agent_git_gate_url.removeprefix("http://").rstrip("/") + return super().git_gate_insteadof_host + + @property + def git_gate_insteadof_scheme(self) -> str: + if self.agent_git_gate_url.startswith("http://"): + return "http" + return super().git_gate_insteadof_scheme diff --git a/bot_bottle/backend/macos_container/cleanup.py b/bot_bottle/backend/macos_container/cleanup.py new file mode 100644 index 0000000..2dae5f8 --- /dev/null +++ b/bot_bottle/backend/macos_container/cleanup.py @@ -0,0 +1,70 @@ +"""Cleanup for the macOS Apple Container backend.""" + +from __future__ import annotations + +import subprocess + +from ...log import info, warn +from . import util as container_mod +from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan + +_PREFIX = "bot-bottle-" +_BUNDLE_PREFIX = "bot-bottle-sidecars-" + + +def _list_prefixed_containers() -> list[str]: + result = subprocess.run( + ["container", "list", "--all", "--quiet"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + warn(f"container list failed: {result.stderr.strip()}") + return [] + return sorted( + name for name in (line.strip() for line in result.stdout.splitlines()) + if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX) + ) + + +def _list_prefixed_networks() -> list[str]: + result = subprocess.run( + ["container", "network", "list", "--quiet"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + return sorted( + name for name in (line.strip() for line in result.stdout.splitlines()) + if name.startswith(_PREFIX) + ) + + +def prepare_cleanup() -> MacosContainerBottleCleanupPlan: + container_mod.require_container() + return MacosContainerBottleCleanupPlan( + containers=tuple(_list_prefixed_containers()), + networks=tuple(_list_prefixed_networks()), + ) + + +def cleanup(plan: MacosContainerBottleCleanupPlan) -> None: + for name in plan.containers: + info(f"container delete --force {name}") + subprocess.run( + ["container", "delete", "--force", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + for name in plan.networks: + info(f"container network delete {name}") + subprocess.run( + ["container", "network", "delete", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py new file mode 100644 index 0000000..b7d261d --- /dev/null +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -0,0 +1,40 @@ +"""Active-agent enumeration for the macOS Apple Container backend.""" + +from __future__ import annotations + +import subprocess + +from ...bottle_state import read_metadata +from .. import ActiveAgent + +_PREFIX = "bot-bottle-" +_SIDECAR_PREFIX = "bot-bottle-sidecars-" + + +def enumerate_active() -> list[ActiveAgent]: + result = subprocess.run( + ["container", "list", "--quiet"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + out: 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( + backend_name="macos-container", + slug=slug, + agent_name=metadata.agent_name if metadata else "?", + started_at=metadata.started_at if metadata else "", + services=(), + label=metadata.label if metadata else "", + color=metadata.color if metadata else "", + )) + return out diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py new file mode 100644 index 0000000..f9fe7a7 --- /dev/null +++ b/bot_bottle/backend/macos_container/launch.py @@ -0,0 +1,426 @@ +"""Launch flow for the macOS Apple Container backend. + +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 + +import dataclasses +import os +import shutil +import subprocess +from contextlib import ExitStack, contextmanager +from pathlib import Path +from typing import Callable, Generator + +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 ...util import expand_tilde +from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT +from ..docker.git_gate import ( + GIT_GATE_ACCESS_HOOK_IN_CONTAINER, + GIT_GATE_CREDS_DIR_IN_CONTAINER, + GIT_GATE_ENTRYPOINT_IN_CONTAINER, + GIT_GATE_HOOK_IN_CONTAINER, +) +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) +_AGENT_SLEEP_SECONDS = "2147483647" +_GIT_HTTP_PORT = 9420 +_GIT_GATE_READY_FILE = "/run/git-gate/ready" + + +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]: + """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: + 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) + _stage_git_gate(plan, 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), + ) + 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 _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}/" + git_gate_url = "" + if plan.git_gate_plan.upstreams: + git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}" + return dataclasses.replace( + plan, + agent_proxy_url=proxy_url, + agent_git_gate_url=git_gate_url, + agent_supervise_url=supervise_url, + ) + + +def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None: + gp = plan.git_gate_plan + if not gp.upstreams: + return + + container_mod.exec_container( + sidecar_name, + [ + "mkdir", + "-p", + str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent), + GIT_GATE_CREDS_DIR_IN_CONTAINER, + "/git", + str(Path(_GIT_GATE_READY_FILE).parent), + ], + ) + + for host_path, container_path in _git_gate_files(plan): + container_mod.copy_into_container( + sidecar_name, host_path, container_path, + ) + + container_mod.exec_container( + sidecar_name, + [ + "sh", + "-c", + "chmod 755 " + f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} " + f"{GIT_GATE_HOOK_IN_CONTAINER} " + f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && " + f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && " + f"touch {_GIT_GATE_READY_FILE}", + ], + ) + + +def _git_gate_files( + plan: MacosContainerBottlePlan, +) -> tuple[tuple[str, str], ...]: + gp = plan.git_gate_plan + files: list[tuple[str, str]] = [ + (str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER), + (str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER), + (str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER), + ] + for upstream in gp.upstreams: + files.append(( + expand_tilde(upstream.identity_file), + f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key", + )) + if upstream.known_hosts_file: + files.append(( + str(upstream.known_hosts_file), + f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts", + )) + return tuple(files) + + +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", _AGENT_SLEEP_SECONDS] + return argv + + +def _sidecar_dns() -> str: + return container_mod.dns_server() + + +def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: + daemons = ["egress"] + if plan.git_gate_plan.upstreams: + daemons += ["git-gate", "git-http"] + 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.git_gate_plan.upstreams: + env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}") + 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/resolve_plan.py b/bot_bottle/backend/macos_container/resolve_plan.py new file mode 100644 index 0000000..021571b --- /dev/null +++ b/bot_bottle/backend/macos_container/resolve_plan.py @@ -0,0 +1,44 @@ +"""Prepare step for the macOS Apple Container backend.""" + +from __future__ import annotations + +from pathlib import Path + +from ...agent_provider import AgentProvisionPlan +from ...egress import EgressPlan +from ...env import ResolvedEnv +from ...git_gate import GitGatePlan +from ...supervise import SupervisePlan +from .. import BottleSpec +from . import util as container_mod +from .bottle_plan import MacosContainerBottlePlan + + +def preflight() -> None: + container_mod.require_container() + + +def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]: + return dict(resolved_env.literals) + + +def resolve_plan( + spec: BottleSpec, + slug: str, + resolved_env: ResolvedEnv, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + supervise_plan: SupervisePlan | None, + git_gate_plan: GitGatePlan, + stage_dir: Path, +) -> MacosContainerBottlePlan: + return MacosContainerBottlePlan( + spec=spec, + stage_dir=stage_dir, + slug=slug, + forwarded_env=dict(resolved_env.forwarded), + git_gate_plan=git_gate_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + agent_provision=agent_provision_plan, + ) diff --git a/bot_bottle/backend/macos_container/util.py b/bot_bottle/backend/macos_container/util.py new file mode 100644 index 0000000..d2c2a9b --- /dev/null +++ b/bot_bottle/backend/macos_container/util.py @@ -0,0 +1,374 @@ +"""Host-side primitives for Apple's `container` CLI.""" + +from __future__ import annotations + +import json +import os +import ipaddress +import platform +import shutil +import subprocess +import time +from typing import Iterable + +from ...log import die, info + + +_CONTAINER = "container" +_DEFAULT_DNS = "1.1.1.1" + + +def is_macos() -> bool: + return platform.system() == "Darwin" + + +def is_available() -> bool: + return is_macos() and shutil.which(_CONTAINER) is not None + + +def require_container() -> None: + """Fail with an install pointer if Apple Container is unavailable.""" + if not is_macos(): + info("BOT_BOTTLE_BACKEND=macos-container requires macOS.") + die("macos-container backend is only supported on macOS") + if shutil.which(_CONTAINER) is None: + info("Apple Container is required but was not found on PATH.") + info("Install: https://github.com/apple/container/releases") + 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)" + ) + _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 + + +def container_exists(name: str) -> bool: + result = subprocess.run( + [_CONTAINER, "list", "--all", "--quiet"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return False + return name in {line.strip() for line in result.stdout.splitlines()} + + +def force_remove_container(name: str) -> None: + if container_exists(name): + subprocess.run( + [_CONTAINER, "delete", "--force", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + +def copy_into_container(name: str, host_path: str, container_path: str) -> None: + cmd = [_CONTAINER, "cp", host_path, f"{name}:{container_path}"] + result = _run_container_op(cmd) + if result.returncode != 0: + die( + f"container cp into {name}:{container_path} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def exec_container(name: str, argv: list[str]) -> None: + result = _run_container_op([_CONTAINER, "exec", name, *argv]) + if result.returncode != 0: + die( + f"container exec in {name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + for _ in range(19): + if result.returncode == 0: + return result + time.sleep(0.1) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + ) + return result + + +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`. + + The command returns JSON on current Apple Container releases. Keep + parsing narrow and fatal so callers do not cache on an empty key. + """ + import json + + result = subprocess.run( + [_CONTAINER, "image", "inspect", ref], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + die( + f"container image inspect for {ref!r} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + try: + data = json.loads(result.stdout or "{}") + except json.JSONDecodeError as exc: + die(f"container image inspect for {ref!r} returned malformed JSON: {exc}") + if isinstance(data, list) and data: + data = data[0] + if isinstance(data, dict): + value = data.get("id") or data.get("digest") or data.get("ID") + if value: + return str(value) + die(f"container image inspect for {ref!r} did not include an image id") + raise AssertionError("unreachable") + + +def save(ref: str, output: str) -> None: + subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True) + + +def _silent_run(cmd: Iterable[str]) -> int: + return subprocess.run( + list(cmd), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 4795eac..a658b56 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -47,7 +47,7 @@ def cmd_start(argv: list[str]) -> int: default=None, help=( "backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND " - "or 'smolmachines'). Overrides the env var when set." + "or host auto-selection). Overrides the env var when set." ), ) parser.add_argument( @@ -107,8 +107,8 @@ def prepare_with_preflight( injected callable, prompt y/N via the injected callable. `backend_name` selects which backend prepares the plan - (`None` → `$BOT_BOTTLE_BACKEND` → `smolmachines`). The CLI passes - whatever `--backend` resolved to. + (`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI + passes whatever `--backend` resolved to. Returns `(plan, identity)`. `plan` is None on dry-run or operator-N, but `identity` is set as soon as `backend.prepare` diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index 9ea62b8..835c193 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -59,6 +59,7 @@ class _DaemonSpec: # reads to inject `Authorization` headers on configured routes; # no other daemon in the bundle should see these values. _EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) +_READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http") def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]: @@ -82,6 +83,22 @@ _DAEMONS: tuple[_DaemonSpec, ...] = ( ) +def _argv_for_daemon(name: str, argv: Sequence[str], env: dict[str, str]) -> list[str]: + ready_file = env.get("BOT_BOTTLE_GIT_GATE_READY_FILE", "").strip() + if name not in _READY_GATED_DAEMONS or not ready_file: + return list(argv) + return [ + "/bin/sh", + "-c", + "while [ ! -f \"$BOT_BOTTLE_GIT_GATE_READY_FILE\" ]; do " + "sleep 0.1; " + "done; " + "exec \"$@\"", + name, + *argv, + ] + + def _selected_daemons( env: dict[str, str], all_daemons: Sequence[_DaemonSpec] | None = None, @@ -118,12 +135,13 @@ def _pump(name: str, stream: IO[bytes]) -> None: def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]: + env = _env_for_daemon(spec.name, dict(os.environ)) proc = subprocess.Popen( - list(spec.argv), + _argv_for_daemon(spec.name, spec.argv, env), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, - env=_env_for_daemon(spec.name, dict(os.environ)), + env=env, ) threading.Thread( target=_pump, args=(spec.name, proc.stdout), daemon=True diff --git a/docs/prds/prd-new-macos-container-backend.md b/docs/prds/prd-new-macos-container-backend.md new file mode 100644 index 0000000..fe68a65 --- /dev/null +++ b/docs/prds/prd-new-macos-container-backend.md @@ -0,0 +1,190 @@ +# PRD prd-new: macOS Container backend + +- **Status:** Draft +- **Author:** Codex +- **Created:** 2026-06-10 +- **Issue:** #220 + +## Summary + +Add a `macos-container` backend that integrates Apple's `container` +CLI as a host runtime on macOS. The shipped slices register the +backend, implement reusable host primitives (`build`, `exec`, `cp`, +image inspection, cleanup, active enumeration), make launch runnable +with the proven two-network sidecar topology, and add real-runtime +coverage without weakening bot-bottle's sidecar egress model. + +## Problem + +bot-bottle currently has two local execution paths: + +- `docker`, which runs the whole bottle topology through Docker + Compose. +- `smolmachines`, which runs the agent in smolvm but still depends on + Docker for the sidecar bundle and image-building pipeline. + +Issue #220 explored removing Docker as a host dependency. A follow-up +review comment verified that smolvm can publish guest ports back to +host loopback and that another smolvm guest can reach that service +through the existing per-bottle loopback alias plus `--allow-cidr` +path. That keeps the VM-contained sidecar direction viable and rejects +the host-process sidecar fallback. + +Apple's `container` CLI is another macOS-native way to run OCI images +as lightweight Linux VMs. Its current command surface includes +Docker-like `build`, `run`, `exec`, `cp`, port publishing, image +inspection, and user-defined networks. That makes it a plausible local +backend, but it does not remove the need to preserve bot-bottle's +sidecar enforcement property: the agent must not have a direct egress +path around the egress sidecar. + +## Goals / Success Criteria + +- `--backend=macos-container` and + `BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing + backend selector. +- Compatible macOS hosts default to `macos-container` when + `BOT_BOTTLE_BACKEND` and `--backend` are both unset. +- Backend availability is true only on macOS hosts with `container` on + `PATH`. +- The backend has tested wrappers for Apple Container image build, + image inspection, container `exec`, container `cp`, cleanup, and + active-agent enumeration. +- 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 + +- Do not remove or deprecate the Docker backend. +- Do not remove or deprecate the smolmachines backend. +- Do not run sidecar daemons as host processes. +- Do not launch a degraded backend where the agent can bypass the + egress sidecar through direct network access. +- Do not require Docker Desktop as part of the macOS Container backend. + +## Design + +### Backend name + +The selectable backend name is `macos-container`. The Python package +uses `bot_bottle.backend.macos_container` because module names cannot +contain hyphens. + +### Availability and preflight + +`MacosContainerBottleBackend.is_available()` returns true only when: + +- `platform.system() == "Darwin"` +- `container` is discoverable on `PATH` + +`prepare()` calls `require_container()`, which produces a concrete +install pointer and rejects non-macOS hosts. + +### Implemented primitives + +The backend owns an Apple Container wrapper module instead of reusing +Docker wrappers. The wrapper maps bot-bottle's backend needs to +Apple's CLI: + +| bot-bottle need | Apple Container command | +|---|---| +| Build provider image | `container build -t [-f Dockerfile] ` | +| Run agent commands | `container exec [--interactive --tty] ...` | +| Copy files into guest | `container cp :` | +| Inspect image identity | `container image inspect ` | +| Cleanup stale containers | `container delete --force ` | +| Cleanup stale networks | `container network delete ` | +| Active enumeration | `container list --quiet` | + +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 topology + +`launch()` uses Apple Container's two-network topology: + +- 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. + +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 + +1. Register `macos-container`, add availability/preflight, bottle + handle, utility wrappers, cleanup, active enumeration, unit tests, + and this PRD. +2. Spike Apple Container networking against real macOS 26 hosts: + repeated `--network`, internal network egress behavior, published + loopback reachability from another container, DNS behavior, and + labels/JSON output stability. +3. Implement launch once the enforcement shape is proven. Reuse the + existing sidecar bundle image and daemon subset env contract where + possible. +4. Add real-runtime integration tests guarded by `container` presence + and macOS version. +5. Consider moving smolmachines sidecar/image-building work to + VM-contained or Apple Container-backed execution only after the + `macos-container` launch path is trustworthy. + +## Testing Strategy + +- Unit tests cover backend registration through `known_backend_names`. +- Unit tests cover availability/preflight behavior without requiring + macOS. +- Unit tests cover `MacosContainerBottle` command construction and + stdin-based shell execution. +- Unit tests cover cleanup and active enumeration parsing. +- 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 + +- [Issue #220 review comment](https://gitea.dideric.is/didericis/bot-bottle/issues/220#issuecomment-1980): + smolvm `--port/-p` can expose a guest service to host loopback, and + another smolvm guest can reach it through the existing per-bottle + loopback alias path. +- Apple Container command reference: `container run`, `build`, `exec`, + port publishing, and network commands. 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_backend_selection.py b/tests/unit/test_backend_selection.py index 61aeb89..4db3932 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -32,8 +32,35 @@ class TestGetBottleBackend(unittest.TestCase): b = get_bottle_backend() self.assertEqual("smolmachines", b.name) - def test_default_smolmachines(self): - with patch.dict(os.environ, {}, clear=True): + def test_default_macos_container_when_available(self): + class _FakeBackend: + name = "macos-container" + + def is_available(self) -> bool: + return True + + with patch.dict(os.environ, {}, clear=True), \ + patch.object(backend_mod, "_BACKENDS", { + "macos-container": _FakeBackend(), + "smolmachines": _FakeBackend(), + }): + b = get_bottle_backend() + self.assertEqual("macos-container", b.name) + + def test_default_smolmachines_when_macos_container_unavailable(self): + class _FakeBackend: + def __init__(self, name: str, available: bool) -> None: + self.name = name + self._available = available + + def is_available(self) -> bool: + return self._available + + with patch.dict(os.environ, {}, clear=True), \ + patch.object(backend_mod, "_BACKENDS", { + "macos-container": _FakeBackend("macos-container", False), + "smolmachines": _FakeBackend("smolmachines", False), + }): b = get_bottle_backend() self.assertEqual("smolmachines", b.name) @@ -44,8 +71,11 @@ class TestGetBottleBackend(unittest.TestCase): class TestKnownBackendNames(unittest.TestCase): - def test_returns_both_backends_sorted(self): - self.assertEqual(("docker", "smolmachines"), known_backend_names()) + def test_returns_backends_sorted(self): + self.assertEqual( + ("docker", "macos-container", "smolmachines"), + known_backend_names(), + ) class TestEnumerateActiveAgents(unittest.TestCase): diff --git a/tests/unit/test_macos_container_bottle.py b/tests/unit/test_macos_container_bottle.py new file mode 100644 index 0000000..04e1315 --- /dev/null +++ b/tests/unit/test_macos_container_bottle.py @@ -0,0 +1,71 @@ +"""Unit: Apple Container bottle command construction.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from bot_bottle.backend.macos_container.bottle import MacosContainerBottle + + +class TestMacosContainerBottle(unittest.TestCase): + def test_agent_argv_uses_container_exec(self): + bottle = MacosContainerBottle( + "bot-bottle-dev-abc", + lambda: None, + None, + agent_command="codex", + ) + self.assertEqual( + [ + "container", "exec", "--interactive", "--tty", + "bot-bottle-dev-abc", "codex", "run", + ], + bottle.agent_argv(["run"]), + ) + + def test_agent_argv_includes_workdir(self): + bottle = MacosContainerBottle( + "bot-bottle-dev-abc", + lambda: None, + None, + agent_workdir="/home/node/workspace", + ) + self.assertEqual( + [ + "container", "exec", "--interactive", "--tty", + "--workdir", "/home/node/workspace", + "bot-bottle-dev-abc", "claude", + ], + bottle.agent_argv([]), + ) + + def test_exec_pipes_script_to_shell(self): + bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None) + with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run: + run.return_value.returncode = 7 + run.return_value.stdout = "out" + run.return_value.stderr = "err" + result = bottle.exec("echo hi", user="root") + self.assertEqual(7, result.returncode) + self.assertEqual( + [ + "container", "exec", "--user", "root", "--interactive", + "bot-bottle-dev-abc", "sh", "-s", + ], + run.call_args.args[0], + ) + self.assertEqual("echo hi", run.call_args.kwargs["input"]) + + def test_cp_in_uses_container_cp(self): + bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None) + with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run: + bottle.cp_in("/tmp/src", "/home/node/src") + self.assertEqual( + ["container", "cp", "/tmp/src", "bot-bottle-dev-abc:/home/node/src"], + run.call_args.args[0], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macos_container_cleanup.py b/tests/unit/test_macos_container_cleanup.py new file mode 100644 index 0000000..fc2d980 --- /dev/null +++ b/tests/unit/test_macos_container_cleanup.py @@ -0,0 +1,70 @@ +"""Unit: Apple Container cleanup/enumeration helpers.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from bot_bottle.backend.macos_container import cleanup, enumerate as enum_mod +from bot_bottle.backend.macos_container.bottle_cleanup_plan import ( + MacosContainerBottleCleanupPlan, +) + + +class TestMacosContainerCleanup(unittest.TestCase): + def test_lists_prefixed_containers(self): + completed = cleanup.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n", + stderr="", + ) + with patch.object(cleanup.subprocess, "run", return_value=completed): + self.assertEqual( + ["bot-bottle-a", "bot-bottle-sidecars-a"], + cleanup._list_prefixed_containers(), + ) + + def test_cleanup_deletes_containers_and_networks(self): + plan = MacosContainerBottleCleanupPlan( + containers=("bot-bottle-a",), + networks=("bot-bottle-net-a",), + ) + with patch.object(cleanup.subprocess, "run") as run: + cleanup.cleanup(plan) + self.assertEqual( + ["container", "delete", "--force", "bot-bottle-a"], + run.call_args_list[0].args[0], + ) + self.assertEqual( + ["container", "network", "delete", "bot-bottle-net-a"], + run.call_args_list[1].args[0], + ) + + +class TestMacosContainerEnumerate(unittest.TestCase): + def test_enumerate_active_reads_metadata(self): + completed = enum_mod.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n", + stderr="", + ) + + class _Metadata: + agent_name = "impl" + started_at = "2026-06-10T00:00:00Z" + label = "Implement" + color = "blue" + + with patch.object(enum_mod.subprocess, "run", return_value=completed), \ + patch.object(enum_mod, "read_metadata", return_value=_Metadata()): + agents = enum_mod.enumerate_active() + self.assertEqual(1, len(agents)) + self.assertEqual("macos-container", agents[0].backend_name) + self.assertEqual("a", agents[0].slug) + self.assertEqual("impl", agents[0].agent_name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macos_container_launch.py b/tests/unit/test_macos_container_launch.py new file mode 100644 index 0000000..948eadc --- /dev/null +++ b/tests/unit/test_macos_container_launch.py @@ -0,0 +1,262 @@ +"""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: + key_path = stage_dir / "origin-key" + key_path.write_text("key\n", encoding="utf-8") + known_hosts_path = stage_dir / "origin-known-hosts" + known_hosts_path.write_text("example.com ssh-ed25519 AAAA\n", encoding="utf-8") + entrypoint = stage_dir / "git_gate_entrypoint.sh" + entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") + hook = stage_dir / "git_gate_pre_receive.sh" + hook.write_text("#!/bin/sh\n", encoding="utf-8") + access_hook = stage_dir / "git_gate_access_hook.sh" + access_hook.write_text("#!/bin/sh\n", encoding="utf-8") + upstream = SimpleNamespace( + name="origin", + identity_file=str(key_path), + known_hosts_file=known_hosts_path, + ) + git_gate_plan = SimpleNamespace( + upstreams=(upstream,), + entrypoint_script=entrypoint, + hook_script=hook, + access_hook_script=access_hook, + ) + 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( + spec=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_daemons_are_ready_gated(self): + plan = _plan(stage_dir=self.stage_dir, git=True) + self.assertEqual( + ("egress", "git-gate", "git-http"), + launch._sidecar_daemons(plan), + ) + self.assertIn( + "BOT_BOTTLE_GIT_GATE_READY_FILE=/run/git-gate/ready", + launch._sidecar_env_entries(plan), + ) + + def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self): + plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True) + with patch.object(launch.dataclasses, "replace") as replace: + launch._stamp_agent_urls(plan, "192.168.128.2") + replace.assert_called_once_with( + plan, + agent_proxy_url="http://192.168.128.2:9099", + agent_git_gate_url="http://192.168.128.2:9420", + agent_supervise_url="http://192.168.128.2:9100/", + ) + + def test_macos_plan_uses_http_git_gate_rewrites(self): + base = _plan( + stage_dir=self.stage_dir, + git=True, + agent_git_gate_url="http://192.168.128.2:9420", + ) + plan = MacosContainerBottlePlan( + spec=base.spec, + stage_dir=base.stage_dir, + git_gate_plan=base.git_gate_plan, + egress_plan=base.egress_plan, + supervise_plan=base.supervise_plan, + agent_provision=base.agent_provision, + slug=base.slug, + forwarded_env=base.forwarded_env, + agent_git_gate_url=base.agent_git_gate_url, + ) + self.assertEqual( + "192.168.128.2:9420", + plan.git_gate_insteadof_host, + ) + self.assertEqual("http", plan.git_gate_insteadof_scheme) + + def test_stage_git_gate_copies_files_and_releases_ready_marker(self): + plan = _plan(stage_dir=self.stage_dir, git=True) + with ( + patch.object(launch.container_mod, "exec_container") as exec_container, + patch.object(launch.container_mod, "copy_into_container") as copy_in, + ): + launch._stage_git_gate(plan, "sidecar") + + exec_container.assert_any_call( + "sidecar", + [ + "mkdir", + "-p", + "/etc/git-gate", + "/git-gate/creds", + "/git", + "/run/git-gate", + ], + ) + copied = [call.args for call in copy_in.call_args_list] + self.assertIn( + ( + "sidecar", + str(self.stage_dir / "git_gate_entrypoint.sh"), + "/git-gate-entrypoint.sh", + ), + copied, + ) + self.assertIn( + ( + "sidecar", + str(self.stage_dir / "origin-key"), + "/git-gate/creds/origin-key", + ), + copied, + ) + self.assertIn( + ( + "sidecar", + str(self.stage_dir / "origin-known-hosts"), + "/git-gate/creds/origin-known_hosts", + ), + copied, + ) + self.assertIn( + "touch /run/git-gate/ready", + exec_container.call_args_list[-1].args[1][-1], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macos_container_util.py b/tests/unit/test_macos_container_util.py new file mode 100644 index 0000000..9789a29 --- /dev/null +++ b/tests/unit/test_macos_container_util.py @@ -0,0 +1,203 @@ +"""Unit: Apple Container utility helpers.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from bot_bottle.backend.macos_container import util + + +class TestMacosContainerAvailability(unittest.TestCase): + def test_available_only_on_macos_with_container(self): + with patch.object(util.platform, "system", return_value="Darwin"), \ + patch.object(util.shutil, "which", return_value="/usr/local/bin/container"): + self.assertTrue(util.is_available()) + + def test_not_available_off_macos(self): + with patch.object(util.platform, "system", return_value="Linux"), \ + patch.object(util.shutil, "which", return_value="/usr/local/bin/container"): + self.assertFalse(util.is_available()) + + def test_require_container_dies_when_missing(self): + with patch.object(util.platform, "system", return_value="Darwin"), \ + patch.object(util.shutil, "which", return_value=None), \ + patch.object(util, "die", side_effect=SystemExit("die")): + with self.assertRaises(SystemExit): + util.require_container() + + +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): + 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", + "--dns", "9.9.9.9", "-f", "/repo/Dockerfile", "/repo", + ], + 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, + ) + + def test_container_exists_parses_quiet_list(self): + completed = util.subprocess.CompletedProcess( + args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="", + ) + with patch.object(util.subprocess, "run", return_value=completed): + self.assertTrue(util.container_exists("bot-bottle-a")) + self.assertFalse(util.container_exists("bot-bottle-b")) + + def test_image_id_reads_json_digest(self): + completed = util.subprocess.CompletedProcess( + args=[], returncode=0, stdout='{"digest":"sha256:abc"}', stderr="", + ) + 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() diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_sidecar_init.py index cb77a35..b47f913 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_sidecar_init.py @@ -21,6 +21,7 @@ from unittest.mock import patch from bot_bottle.sidecar_init import ( _DaemonSpec, _Supervisor, + _argv_for_daemon, _env_for_daemon, _selected_daemons, ) @@ -120,6 +121,28 @@ class TestSelectedDaemons(unittest.TestCase): self.assertEqual([d.name for d in got], ["egress", "git-gate"]) +class TestDaemonArgv(unittest.TestCase): + def test_git_daemons_wait_for_ready_marker_when_configured(self): + argv = _argv_for_daemon( + "git-gate", + ("/bin/sh", "/git-gate-entrypoint.sh"), + {"BOT_BOTTLE_GIT_GATE_READY_FILE": "/run/git-gate/ready"}, + ) + self.assertEqual("/bin/sh", argv[0]) + self.assertEqual("-c", argv[1]) + self.assertIn("BOT_BOTTLE_GIT_GATE_READY_FILE", argv[2]) + self.assertEqual("git-gate", argv[3]) + self.assertEqual(["/bin/sh", "/git-gate-entrypoint.sh"], argv[4:]) + + def test_non_git_daemon_does_not_wait_for_ready_marker(self): + argv = _argv_for_daemon( + "egress", + ("/bin/sh", "/app/egress-entrypoint.sh"), + {"BOT_BOTTLE_GIT_GATE_READY_FILE": "/run/git-gate/ready"}, + ) + self.assertEqual(["/bin/sh", "/app/egress-entrypoint.sh"], argv) + + class TestSupervisor(unittest.TestCase): """End-to-end: drive `_Supervisor` directly with fake commands. We don't go through `main()` because main installs signal