From 932e71c0bf0a02886e4808c92dcb1faf797b7a34 Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 10 Jun 2026 21:41:08 -0400 Subject: [PATCH] fix(macos-container): make backend the macos default --- AGENTS.md | 11 ++++--- README.md | 13 +++++--- bot_bottle/backend/__init__.py | 18 ++++++++--- bot_bottle/backend/macos_container/backend.py | 2 +- bot_bottle/backend/macos_container/launch.py | 4 +-- bot_bottle/cli/start.py | 6 ++-- docs/prds/prd-new-macos-container-backend.md | 17 +++++----- tests/unit/test_backend_selection.py | 31 +++++++++++++++++-- 8 files changed, 72 insertions(+), 30 deletions(-) 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 835b6ec..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 @@ -553,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/backend.py b/bot_bottle/backend/macos_container/backend.py index 8826aa3..14a0496 100644 --- a/bot_bottle/backend/macos_container/backend.py +++ b/bot_bottle/backend/macos_container/backend.py @@ -25,7 +25,7 @@ from .bottle_plan import MacosContainerBottlePlan class MacosContainerBottleBackend( BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"] ): - """Experimental Apple Container backend. Selected by + """Apple Container backend. Selected by `BOT_BOTTLE_BACKEND=macos-container` or `--backend=macos-container`.""" diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index a7dfea3..59f5f95 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -36,7 +36,7 @@ from .bottle_plan import MacosContainerBottlePlan _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) -_SIDECAR_SLEEP_SECONDS = "2147483647" +_AGENT_SLEEP_SECONDS = "2147483647" def internal_network_name(slug: str) -> str: @@ -259,7 +259,7 @@ def _agent_run_argv( ] for entry in _agent_env_entries(plan, sidecar_ip): argv += ["--env", entry] - argv += [plan.image, "sleep", _SIDECAR_SLEEP_SECONDS] + argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS] return argv 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/docs/prds/prd-new-macos-container-backend.md b/docs/prds/prd-new-macos-container-backend.md index 570595c..fe68a65 100644 --- a/docs/prds/prd-new-macos-container-backend.md +++ b/docs/prds/prd-new-macos-container-backend.md @@ -7,13 +7,12 @@ ## 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 and implements reusable host primitives -(`build`, `exec`, `cp`, image inspection, cleanup, active -enumeration). Follow-up slices make launch runnable with the proven -two-network sidecar topology and add real-runtime coverage, without -weakening bot-bottle's sidecar egress model. +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 @@ -44,6 +43,8 @@ path around the egress sidecar. - `--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, @@ -62,7 +63,7 @@ path around the egress sidecar. ## Non-goals - Do not remove or deprecate the Docker backend. -- Do not change the default backend from `smolmachines`. +- 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. diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index 55e098a..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)