feat(backend): default to smolmachines

This commit is contained in:
2026-06-09 03:27:31 +00:00
parent cc1d986a74
commit 1bebb7467f
14 changed files with 47 additions and 29 deletions
+10 -6
View File
@@ -2,11 +2,15 @@
## What this is ## What this is
bot-bottle spins up an isolated container for running AI coding agents with a bot-bottle spins up an isolated backend runtime for running AI coding agents
curated set of skills and env vars. The point is to run agents with broad with a curated set of skills and env vars. The point is to run agents with
permissions inside a sandbox, so a misbehaving agent cannot reach the host. broad permissions inside a sandbox, so a misbehaving agent cannot reach the
A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
the container 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
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
@@ -17,7 +21,7 @@ the container lifecycle and the copying of skills and env vars into it.
## Non-goals ## Non-goals
- Communicating between agents directly - Communicating between agents directly
- Self hosted VMs (v1 uses local Docker containers, not VMs) - Removing the Docker backend
- Advanced agent auditing (lean on git history for auditing) - Advanced agent auditing (lean on git history for auditing)
## Repository layout ## Repository layout
+11 -4
View File
@@ -20,14 +20,19 @@
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
- **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host. - **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host.
- **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top. - **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top.
- **Parallel, isolated bottles** — each bottle is its own per-agent Docker `--internal` network; 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)** — opt-in `BOT_BOTTLE_BACKEND=smolmachines` runs the agent in a libkrun micro-VM with the sidecar bundle still in Docker. - **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`.
## Architecture ## Architecture
A bottle is two containers per agent: an `agent` container, and a `sidecars` container that bundles egress + git-gate + supervise behind a Python init supervisor. They share a per-agent Docker `--internal` network; the agent has no default route off-box. 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 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.
The Docker topology looks like this:
``` ```
host ( ./cli.py ) host ( ./cli.py )
@@ -62,7 +67,9 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
## Quickstart ## Quickstart
Requires Docker on the host and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. 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`.
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where smolvm is not installed.
```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
+3 -3
View File
@@ -25,7 +25,7 @@ backend exposes five methods:
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 (env var; default "docker"). Per PRD 0003 the BOT_BOTTLE_BACKEND (env var; default "smolmachines"). Per PRD 0003 the
manifest does not carry a backend field; the host picks. manifest does not carry a backend field; the host picks.
""" """
@@ -550,11 +550,11 @@ 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 `docker` 3. 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 "docker" resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines"
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}")
+1 -1
View File
@@ -40,7 +40,7 @@ from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND """Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
(default).""" when set to `docker`; retained as a legacy/example backend."""
name = "docker" name = "docker"
+3 -1
View File
@@ -21,7 +21,9 @@ def smolmachines_preflight() -> None:
die( die(
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on " "BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
"PATH. Install with: " "PATH. Install with: "
"curl -sSL https://smolmachines.com/install.sh | sh" "curl -sSL https://smolmachines.com/install.sh | sh. "
"To use the legacy Docker backend instead, set "
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
) )
+2 -2
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 'docker'). Overrides the env var when set." "or 'smolmachines'). Overrides the env var when set."
), ),
) )
parser.add_argument( parser.add_argument(
@@ -114,7 +114,7 @@ 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` → `docker`). The CLI passes (`None` → `$BOT_BOTTLE_BACKEND` → `smolmachines`). The CLI passes
whatever `--backend` resolved to. whatever `--backend` resolved to.
Returns `(plan, identity)`. `plan` is None on dry-run or Returns `(plan, identity)`. `plan` is None on dry-run or
+3 -3
View File
@@ -66,10 +66,10 @@ Update `examples/bottles/dev.md` and `examples/bottles/claude.md` to remove Dock
Tests that exercise the Docker backend explicitly should set `BOT_BOTTLE_BACKEND=docker` rather than relying on the default. Tests that are backend-agnostic continue to use whatever `BOT_BOTTLE_BACKEND` is set to (defaulting to smolmachines in the test environment if available). Tests that exercise the Docker backend explicitly should set `BOT_BOTTLE_BACKEND=docker` rather than relying on the default. Tests that are backend-agnostic continue to use whatever `BOT_BOTTLE_BACKEND` is set to (defaulting to smolmachines in the test environment if available).
## Open questions ## Resolved questions
- **TSI + pipelock (127.0.0.1 passthrough).** The smolmachines research note (`docs/research/smolmachines-as-vm-backend.md`) flags that TSI passthrough to `127.0.0.1` for a host-side pipelock proxy is unverified. This must be smoke-tested before the default switch lands: `curl` from inside the guest → pipelock on host should succeed; `curl` to a non-allowlisted host should be blocked. If TSI blocks loopback traffic, `--outbound-localhost-only` plus `HTTPS_PROXY` in the Smolfile may be the fix. - **TSI + egress proxy loopback.** The implementation uses a per-bottle loopback alias rather than broad `127.0.0.1` passthrough. The smolmachines launch integration test now asserts that the guest receives proxy env vars on a `127.x` alias, can reach an allowlisted host through the proxy, cannot reach the same host directly with proxy vars unset, and cannot reach a non-allowlisted host through the proxy.
- **smolmachines availability check.** `is_available()` on the smolmachines backend returns false if the `smolvm` binary is not on PATH. Should the CLI warn clearly and suggest `BOT_BOTTLE_BACKEND=docker` when smolmachines is unavailable, rather than hard-failing? - **smolmachines availability check.** The smolmachines preflight error points operators at the smolvm installer and explicitly suggests `BOT_BOTTLE_BACKEND=docker` / `--backend=docker` for legacy Docker-backed runs.
## References ## References
+3 -1
View File
@@ -13,4 +13,6 @@ egress:
Common Claude provider boundary. Drop this file into Common Claude provider boundary. Drop this file into
`~/.bot-bottle/bottles/claude.md`, then extend it from task-specific `~/.bot-bottle/bottles/claude.md`, then extend it from task-specific
bottles. bottles. The default smolmachines backend keeps DNS resolution under
the VM-layer egress policy; use `BOT_BOTTLE_BACKEND=docker` only for
legacy Docker-backed runs.
+2 -1
View File
@@ -10,4 +10,5 @@ The `dev` bottle — backs a generic development workflow.
Inherits the Claude provider boundary from `claude`. Drop this file Inherits the Claude provider boundary from `claude`. Drop this file
into `~/.bot-bottle/bottles/dev.md` and any agent referencing into `~/.bot-bottle/bottles/dev.md` and any agent referencing
`bottle: dev` will launch against this infrastructure. `bottle: dev` will launch against this infrastructure. By default,
bot-bottle runs this bottle on the smolmachines backend.
+4 -3
View File
@@ -11,8 +11,9 @@ asserts each one is blocked:
5. Secret exfil via README link pushed through git-gate 5. Secret exfil via README link pushed through git-gate
The suite is backend-agnostic it goes through `get_bottle_backend()` The suite is backend-agnostic it goes through `get_bottle_backend()`
so a future smolmachines backend can be tested by setting so smolmachines can be tested by setting `BOT_BOTTLE_BACKEND=smolmachines`.
`BOT_BOTTLE_BACKEND=smolmachines` without touching this file. When unset, this integration test pins Docker explicitly to preserve
the Docker-backed CI path.
PRD 0022 chunk 1 (this commit): fixture + setUpClass + PRD 0022 chunk 1 (this commit): fixture + setUpClass +
tearDownClass + preflight tool check. Attack tests land in tearDownClass + preflight tool check. Attack tests land in
@@ -146,7 +147,7 @@ class TestSandboxEscape(unittest.TestCase):
cls._stage_dir = Path(tempfile.mkdtemp(prefix="sandbox-escape-stage.")) cls._stage_dir = Path(tempfile.mkdtemp(prefix="sandbox-escape-stage."))
try: try:
backend = get_bottle_backend() backend = get_bottle_backend(backend_name)
plan = backend.prepare(spec, stage_dir=cls._stage_dir) plan = backend.prepare(spec, stage_dir=cls._stage_dir)
cls._identity = plan.slug cls._identity = plan.slug
@@ -56,7 +56,7 @@ class TestSidecarBundleCompose(unittest.TestCase):
stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke.")) stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke."))
try: try:
with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}): with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}):
backend = get_bottle_backend() backend = get_bottle_backend("docker")
spec = BottleSpec( spec = BottleSpec(
manifest=_manifest(), manifest=_manifest(),
agent_name="demo", agent_name="demo",
+2 -2
View File
@@ -32,10 +32,10 @@ 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_docker(self): def test_default_smolmachines(self):
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
b = get_bottle_backend() b = get_bottle_backend()
self.assertEqual("docker", b.name) self.assertEqual("smolmachines", b.name)
def test_unknown_dies(self): def test_unknown_dies(self):
with patch.object(backend_mod, "die", side_effect=SystemExit("die")): with patch.object(backend_mod, "die", side_effect=SystemExit("die")):
+1 -1
View File
@@ -39,7 +39,7 @@ class TestStartBackendFlag(unittest.TestCase):
self.assertEqual("smolmachines", args.backend) self.assertEqual("smolmachines", args.backend)
self.assertEqual("researcher", args.name) self.assertEqual("researcher", args.name)
def test_flag_default_none_means_env_or_docker(self): def test_flag_default_none_means_env_or_default_backend(self):
args = self._build_parser().parse_args(["researcher"]) args = self._build_parser().parse_args(["researcher"])
self.assertIsNone(args.backend) self.assertIsNone(args.backend)
+1
View File
@@ -85,6 +85,7 @@ class TestPreflight(unittest.TestCase):
msg = captured.getvalue() msg = captured.getvalue()
self.assertIn("smolvm", msg) self.assertIn("smolvm", msg)
self.assertIn("smolmachines.com/install.sh", msg) self.assertIn("smolmachines.com/install.sh", msg)
self.assertIn("BOT_BOTTLE_BACKEND=docker", msg)
if __name__ == "__main__": if __name__ == "__main__":