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
bot-bottle spins up an isolated container for running AI coding agents 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 container lifecycle and the copying of skills and env vars into it.
bot-bottle spins up an isolated backend runtime for running AI coding agents
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`.
## Goals
@@ -17,7 +21,7 @@ the container lifecycle and the copying of skills and env vars into it.
## Non-goals
- 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)
## 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.
- **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.
- **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.
- **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
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 )
@@ -62,7 +67,9 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
## 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
./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.
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.
"""
@@ -550,11 +550,11 @@ def get_bottle_backend(
`name` precedence:
1. explicit arg (CLI `--backend=<name>` passes through here)
2. BOT_BOTTLE_BACKEND env var
3. default `docker`
3. 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 "docker"
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines"
if resolved not in _BACKENDS:
known = ", ".join(sorted(_BACKENDS))
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
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
"""Docker backend implementation. Selected by BOT_BOTTLE_BACKEND
(default)."""
when set to `docker`; retained as a legacy/example backend."""
name = "docker"
+3 -1
View File
@@ -21,7 +21,9 @@ def smolmachines_preflight() -> None:
die(
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
"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,
help=(
"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(
@@ -114,7 +114,7 @@ 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` → `docker`). The CLI passes
(`None` → `$BOT_BOTTLE_BACKEND` → `smolmachines`). The CLI passes
whatever `--backend` resolved to.
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).
## 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.
- **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?
- **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.** 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
+3 -1
View File
@@ -13,4 +13,6 @@ egress:
Common Claude provider boundary. Drop this file into
`~/.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
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
The suite is backend-agnostic it goes through `get_bottle_backend()`
so a future smolmachines backend can be tested by setting
`BOT_BOTTLE_BACKEND=smolmachines` without touching this file.
so smolmachines can be tested by setting `BOT_BOTTLE_BACKEND=smolmachines`.
When unset, this integration test pins Docker explicitly to preserve
the Docker-backed CI path.
PRD 0022 chunk 1 (this commit): fixture + setUpClass +
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."))
try:
backend = get_bottle_backend()
backend = get_bottle_backend(backend_name)
plan = backend.prepare(spec, stage_dir=cls._stage_dir)
cls._identity = plan.slug
@@ -56,7 +56,7 @@ class TestSidecarBundleCompose(unittest.TestCase):
stage_dir = Path(tempfile.mkdtemp(prefix="cb-bundle-smoke."))
try:
with patch.dict(os.environ, {"BOT_BOTTLE_SIDECAR_BUNDLE": "1"}):
backend = get_bottle_backend()
backend = get_bottle_backend("docker")
spec = BottleSpec(
manifest=_manifest(),
agent_name="demo",
+2 -2
View File
@@ -32,10 +32,10 @@ class TestGetBottleBackend(unittest.TestCase):
b = get_bottle_backend()
self.assertEqual("smolmachines", b.name)
def test_default_docker(self):
def test_default_smolmachines(self):
with patch.dict(os.environ, {}, clear=True):
b = get_bottle_backend()
self.assertEqual("docker", b.name)
self.assertEqual("smolmachines", b.name)
def test_unknown_dies(self):
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("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"])
self.assertIsNone(args.backend)
+1
View File
@@ -85,6 +85,7 @@ class TestPreflight(unittest.TestCase):
msg = captured.getvalue()
self.assertIn("smolvm", msg)
self.assertIn("smolmachines.com/install.sh", msg)
self.assertIn("BOT_BOTTLE_BACKEND=docker", msg)
if __name__ == "__main__":