diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index aea3343..835b6ec 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -190,7 +190,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 +530,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 +540,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(), } 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..1f6423b --- /dev/null +++ b/bot_bottle/backend/macos_container/backend.py @@ -0,0 +1,81 @@ +"""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"] +): + """Experimental 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() 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..ca01073 --- /dev/null +++ b/bot_bottle/backend/macos_container/bottle_plan.py @@ -0,0 +1,43 @@ +"""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) + + @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 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..d6365a1 --- /dev/null +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -0,0 +1,37 @@ +"""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-" + + +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 + 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..de9e053 --- /dev/null +++ b/bot_bottle/backend/macos_container/launch.py @@ -0,0 +1,34 @@ +"""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. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Callable, Generator + +from ...log import die +from .bottle import MacosContainerBottle +from .bottle_plan import MacosContainerBottlePlan + + +@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." + ) + yield # pragma: no cover 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..775fbe2 --- /dev/null +++ b/bot_bottle/backend/macos_container/util.py @@ -0,0 +1,117 @@ +"""Host-side primitives for Apple's `container` CLI.""" + +from __future__ import annotations + +import platform +import shutil +import subprocess +from typing import Iterable + +from ...log import die, info + + +_CONTAINER = "container" + + +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 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] + if dockerfile: + args.extend(["-f", dockerfile]) + args.append(context) + subprocess.run(args, check=True) + + +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 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/docs/prds/prd-new-macos-container-backend.md b/docs/prds/prd-new-macos-container-backend.md new file mode 100644 index 0000000..706cddf --- /dev/null +++ b/docs/prds/prd-new-macos-container-backend.md @@ -0,0 +1,159 @@ +# PRD prd-new: macOS Container backend + +- **Status:** Draft +- **Author:** Codex +- **Created:** 2026-06-10 +- **Issue:** #220 + +## Summary + +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 +(`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 +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 +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. +- 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 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. + +## Non-goals + +- Do not remove or deprecate the Docker backend. +- Do not change the default backend from `smolmachines`. +- 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 guard + +`launch()` is intentionally not enabled in the first slice. It exits +with a fatal message explaining that sidecar network enforcement still +needs implementation. + +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: + +- 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. + +## 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. +- Future integration tests must run on a host with Apple Container + installed and should verify egress cannot bypass the sidecar. + +## References + +- Issue #220 comment: 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/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index 61aeb89..55e098a 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -44,8 +44,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..d1502f2 --- /dev/null +++ b/tests/unit/test_macos_container_cleanup.py @@ -0,0 +1,67 @@ +"""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\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_util.py b/tests/unit/test_macos_container_util.py new file mode 100644 index 0000000..db58378 --- /dev/null +++ b/tests/unit/test_macos_container_util.py @@ -0,0 +1,60 @@ +"""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_build_image(self): + with patch.object(util.subprocess, "run") as run: + util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile") + self.assertEqual( + [ + "container", "build", "-t", "bot-bottle-agent:latest", + "-f", "/repo/Dockerfile", "/repo", + ], + run.call_args.args[0], + ) + self.assertTrue(run.call_args.kwargs["check"]) + + 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")) + + +if __name__ == "__main__": + unittest.main()