fix(macos-container): make backend the macos default

This commit is contained in:
2026-06-10 21:41:08 -04:00
parent d3b0b330aa
commit 932e71c0bf
8 changed files with 72 additions and 30 deletions
+7 -4
View File
@@ -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 broad permissions inside a sandbox, so a misbehaving agent cannot reach the
host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates 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 runtime lifecycle and the copying of skills and env vars into it.
The default backend is smolmachines on macOS: agents run in a libkrun The default backend on compatible macOS hosts is macos-container:
micro-VM, while the sidecar bundle still uses Docker. The legacy Docker agents and sidecar bundles run through Apple's `container` CLI without
backend remains available with `BOT_BOTTLE_BACKEND=docker` or requiring Docker. The smolmachines backend remains available with
`--backend=docker`. `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 ## Goals
+8 -5
View File
@@ -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. - **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. - **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. - **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. - **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.
- **Legacy Docker backend** — still available for examples, CI, and hosts without smolvm via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. - **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 ## 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. 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 ## 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 <agent>` on hosts where smolvm is not installed. Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
+13 -5
View File
@@ -24,9 +24,10 @@ backend exposes five methods:
enough metadata for callers (CLI `list active`, dashboard enough metadata for callers (CLI `list active`, dashboard
agents pane) to render a row. agents pane) to render a row.
Selection is driven by `--backend` on `start` or Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND
BOT_BOTTLE_BACKEND (env var; default "smolmachines"). Per PRD 0003 the (env var). When neither is set, compatible macOS hosts default to
manifest does not carry a backend field; the host picks. `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 from __future__ import annotations
@@ -553,17 +554,24 @@ def get_bottle_backend(
`name` precedence: `name` precedence:
1. explicit arg (CLI `--backend=<name>` passes through here) 1. explicit arg (CLI `--backend=<name>` passes through here)
2. BOT_BOTTLE_BACKEND env var 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 Dies with a pointer at the known backends if the chosen name
isn't implemented.""" 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: if resolved not in _BACKENDS:
known = ", ".join(sorted(_BACKENDS)) known = ", ".join(sorted(_BACKENDS))
die(f"unknown backend {resolved!r}; known backends: {known}") die(f"unknown backend {resolved!r}; known backends: {known}")
return _BACKENDS[resolved] return _BACKENDS[resolved]
def _default_backend_name() -> str:
if has_backend("macos-container"):
return "macos-container"
return "smolmachines"
def known_backend_names() -> tuple[str, ...]: def known_backend_names() -> tuple[str, ...]:
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by """Sorted tuple of all backend keys in `_BACKENDS`. Used by
argparse (`--backend` choices) and the dashboard's backend argparse (`--backend` choices) and the dashboard's backend
@@ -25,7 +25,7 @@ from .bottle_plan import MacosContainerBottlePlan
class MacosContainerBottleBackend( class MacosContainerBottleBackend(
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"] BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
): ):
"""Experimental Apple Container backend. Selected by """Apple Container backend. Selected by
`BOT_BOTTLE_BACKEND=macos-container` or `BOT_BOTTLE_BACKEND=macos-container` or
`--backend=macos-container`.""" `--backend=macos-container`."""
+2 -2
View File
@@ -36,7 +36,7 @@ from .bottle_plan import MacosContainerBottlePlan
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) _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: def internal_network_name(slug: str) -> str:
@@ -259,7 +259,7 @@ def _agent_run_argv(
] ]
for entry in _agent_env_entries(plan, sidecar_ip): for entry in _agent_env_entries(plan, sidecar_ip):
argv += ["--env", entry] argv += ["--env", entry]
argv += [plan.image, "sleep", _SIDECAR_SLEEP_SECONDS] argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
return argv return argv
+3 -3
View File
@@ -47,7 +47,7 @@ def cmd_start(argv: list[str]) -> int:
default=None, default=None,
help=( help=(
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND " "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( parser.add_argument(
@@ -107,8 +107,8 @@ def prepare_with_preflight(
injected callable, prompt y/N via the injected callable. injected callable, prompt y/N via the injected callable.
`backend_name` selects which backend prepares the plan `backend_name` selects which backend prepares the plan
(`None` → `$BOT_BOTTLE_BACKEND` → `smolmachines`). The CLI passes (`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI
whatever `--backend` resolved to. passes whatever `--backend` resolved to.
Returns `(plan, identity)`. `plan` is None on dry-run or Returns `(plan, identity)`. `plan` is None on dry-run or
operator-N, but `identity` is set as soon as `backend.prepare` operator-N, but `identity` is set as soon as `backend.prepare`
+9 -8
View File
@@ -7,13 +7,12 @@
## Summary ## Summary
Add an experimental `macos-container` backend that integrates Apple's Add a `macos-container` backend that integrates Apple's `container`
`container` CLI as a host runtime on macOS. The first shipped slice CLI as a host runtime on macOS. The shipped slices register the
registers the backend and implements reusable host primitives backend, implement reusable host primitives (`build`, `exec`, `cp`,
(`build`, `exec`, `cp`, image inspection, cleanup, active image inspection, cleanup, active enumeration), make launch runnable
enumeration). Follow-up slices make launch runnable with the proven with the proven two-network sidecar topology, and add real-runtime
two-network sidecar topology and add real-runtime coverage, without coverage without weakening bot-bottle's sidecar egress model.
weakening bot-bottle's sidecar egress model.
## Problem ## Problem
@@ -44,6 +43,8 @@ path around the egress sidecar.
- `--backend=macos-container` and - `--backend=macos-container` and
`BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing `BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing
backend selector. 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 - Backend availability is true only on macOS hosts with `container` on
`PATH`. `PATH`.
- The backend has tested wrappers for Apple Container image build, - The backend has tested wrappers for Apple Container image build,
@@ -62,7 +63,7 @@ path around the egress sidecar.
## Non-goals ## Non-goals
- Do not remove or deprecate the Docker backend. - 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 run sidecar daemons as host processes.
- Do not launch a degraded backend where the agent can bypass the - Do not launch a degraded backend where the agent can bypass the
egress sidecar through direct network access. egress sidecar through direct network access.
+29 -2
View File
@@ -32,8 +32,35 @@ class TestGetBottleBackend(unittest.TestCase):
b = get_bottle_backend() b = get_bottle_backend()
self.assertEqual("smolmachines", b.name) self.assertEqual("smolmachines", b.name)
def test_default_smolmachines(self): def test_default_macos_container_when_available(self):
with patch.dict(os.environ, {}, clear=True): 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() b = get_bottle_backend()
self.assertEqual("smolmachines", b.name) self.assertEqual("smolmachines", b.name)