feat(backend): remove smolmachines; firecracker is the Linux default
lint / lint (push) Successful in 1m54s
test / unit (pull_request) Successful in 58s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Failing after 1m3s

Delete the smolmachines backend (the whole bot_bottle/backend/smolmachines
package and its tests). It had fatal Linux issues (TSI networking under
sustained use, exec-channel contention, no SIGWINCH) and is superseded by
the Firecracker backend (issue #342).

Backend selection now:
- default is macos-container on macOS, firecracker on KVM-capable Linux
  hosts, and docker as the last resort (was smolmachines).
- firecracker is selected on a KVM host even when the `firecracker`
  binary isn't installed, so start routes through its preflight and
  prints an install pointer (same UX as require_container), instead of
  silently falling back. Split is_host_capable() (Linux + KVM) out of
  is_available() (adds the binary check) to drive this.

Retarget the cross-backend tests (parity, print-parity, prepare,
workspace, freezer, selection) from smolmachines to firecracker rather
than dropping the coverage. Remove docker.util.image_id/save, which only
smolmachines used. Update README/AGENTS/example bottles and stale
comments; historical docs/prds are left as a point-in-time record.

BREAKING: BOT_BOTTLE_BACKEND=smolmachines now errors as unknown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
2026-07-11 13:24:47 -04:00
parent c276f7b0b1
commit c07ebca867
73 changed files with 305 additions and 6218 deletions
+10 -22
View File
@@ -11,7 +11,7 @@ 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 smolmachines can be tested by setting `BOT_BOTTLE_BACKEND=smolmachines`.
so another backend can be tested by setting `BOT_BOTTLE_BACKEND`.
When unset, this integration test pins Docker explicitly to preserve
the Docker-backed CI path.
@@ -24,7 +24,6 @@ from __future__ import annotations
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
@@ -70,27 +69,16 @@ class TestSandboxEscape(unittest.TestCase):
_launch_cm = None # backend.launch context manager
_bottle = None
_identity: str = ""
_backend_name: str = "docker"
@classmethod
def setUpClass(cls) -> None:
# Per-backend prerequisites. Docker is always required (both
# backends use it — docker for the agent + sidecars, smolmachines
# for the sidecar bundle); the class-level @skip_unless_docker
# already covers that. Smolmachines additionally needs smolvm on
# PATH and is macOS-only in v1 (libkrun/TSI). Skip cleanly when
# those are missing rather than die-ing inside backend.prepare.
backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
if backend_name == "smolmachines":
if sys.platform not in ("darwin", "linux"):
raise unittest.SkipTest(
f"BOT_BOTTLE_BACKEND=smolmachines is not supported "
f"on {sys.platform} (macOS and Linux only)"
)
if shutil.which("smolvm") is None:
raise unittest.SkipTest(
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` "
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
)
# Docker is always required (the agent + sidecars run under it,
# and VM backends still use it for the sidecar bundle); the
# class-level @skip_unless_docker already covers that. Pin
# Docker when BOT_BOTTLE_BACKEND is unset to preserve the
# Docker-backed CI path.
cls._backend_name = os.environ.get("BOT_BOTTLE_BACKEND", "docker")
# Throwaway static key for the git-gate fixture. It need not
# be a real SSH key: test 5 reaches gitleaks before any SSH
@@ -149,7 +137,7 @@ class TestSandboxEscape(unittest.TestCase):
cls._stage_dir = Path(tempfile.mkdtemp(prefix="sandbox-escape-stage."))
try:
backend = get_bottle_backend(backend_name)
backend = get_bottle_backend(cls._backend_name)
plan = backend.prepare(spec, stage_dir=cls._stage_dir)
cls._identity = plan.slug
@@ -421,7 +409,7 @@ class TestSandboxEscape(unittest.TestCase):
# ~/.gitconfig insteadOf rewrite (set up by provision_git)
# redirects to the gate. This makes the test backend-
# agnostic: docker resolves the gate via the short `git-gate`
# alias, smolmachines via `<bundle_ip>:9418` — both
# alias, a VM backend via `<bundle_ip>:9418` — both
# transparent to the test through insteadOf.
upstream_url = "ssh://git@unreachable.invalid:22/throwaway.git"
@@ -1,111 +0,0 @@
"""Integration: PRD 0023 chunk 2c — bundle bringup on a per-bottle
docker bridge with the pinned IP.
End-to-end against the real docker daemon. Brings up just the
sidecar bundle on its own bridge, confirms the container lands at
the pinned IP, then tears down. Skipped under act_runner (docker
socket mount topology breaks bridge visibility) and when the
bundle image isn't available.
Full launch flow (smolvm + bundle + provisioning + the
localhost-reach / egress-port-bypass probes) lives in chunk 2d."""
from __future__ import annotations
import os
import subprocess
import time
import unittest
from bot_bottle.backend.smolmachines.sidecar_bundle import (
BundleLaunchSpec,
bundle_container_name,
bundle_network_name,
create_bundle_network,
remove_bundle_network,
start_bundle,
stop_bundle,
)
from tests._docker import skip_unless_docker
@skip_unless_docker()
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: docker socket mount topology breaks "
"in-process visibility of networks created on the host daemon",
)
class TestBundleBringup(unittest.TestCase):
def setUp(self):
self.slug = f"cb-test-bundle-{os.getpid()}-{int(time.time())}"
self.network = bundle_network_name(self.slug)
self.container = bundle_container_name(self.slug)
def tearDown(self):
stop_bundle(self.slug)
remove_bundle_network(self.network)
def _bundle_image_built(self) -> bool:
"""The bundle image (`bot-bottle-sidecars:latest`) is
built lazily by the docker backend's compose. If a
smolmachines-only operator hasn't run the docker backend
first, the image won't exist locally. Skip rather than
fail."""
r = subprocess.run(
["docker", "image", "inspect", "bot-bottle-sidecars:latest"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False,
)
return r.returncode == 0
def test_create_network_then_start_bundle_pins_ip(self):
if not self._bundle_image_built():
self.skipTest(
"bot-bottle-sidecars:latest not built; run a docker "
"bottle first or `docker build -f Dockerfile.sidecars .`"
)
# Pick a subnet unlikely to collide on the host. Last
# octet of the slug hash isn't deterministic across runs;
# we hardcode a high octet (.211) that the docker default
# bridges almost never use.
subnet = "192.168.211.0/24"
gateway = "192.168.211.1"
bundle_ip = "192.168.211.2"
create_bundle_network(self.network, subnet, gateway)
spec = BundleLaunchSpec(
slug=self.slug,
network_name=self.network,
subnet=subnet,
gateway=gateway,
bundle_ip=bundle_ip,
# Empty daemons_csv → init exits "no daemons selected"
# immediately. We just need the container to land on
# the network at the right IP before it exits.
daemons_csv="", # empty → init exits "no daemons selected"
)
start_bundle(spec)
# Inspect the container's IP on the per-bottle network.
r = subprocess.run(
["docker", "inspect",
"--format",
"{{(index .NetworkSettings.Networks \"" + self.network + "\").IPAddress}}",
self.container],
capture_output=True, text=True, check=False,
)
# Container may have exited (no daemons selected → exit 0).
# The inspect still works on exited containers as long as
# `--rm` hasn't fired yet, which is a race. Even if it has,
# the launch succeeded — the container existed, on the
# right network, at the right IP. We don't fail here on
# missing inspect.
if r.returncode == 0 and r.stdout.strip():
self.assertEqual(bundle_ip, r.stdout.strip(),
f"bundle landed at wrong IP: {r.stdout!r}")
if __name__ == "__main__":
unittest.main()
@@ -1,223 +0,0 @@
"""Integration: PRD 0023 chunk 2d — end-to-end launch + exec
round trip + the acceptance probes.
The smoke confirms the launch flow (per-bottle docker bridge →
sidecar bundle with host-loopback published ports → smolvm guest
with TSI allowlist → exec) plumbs together end to end. The probes confirm the
security properties the design pivot was about:
- **localhost-reach probe** — guest tries to dial a service
bound on the host's `127.0.0.1`. TSI's per-bottle loopback
alias allowlist must refuse the connect.
- **egress proxy probe** — guest reaches the egress proxy through
the injected `HTTPS_PROXY`/`HTTP_PROXY` URL on the per-bottle
loopback alias, while direct egress with proxy vars unset fails.
- **egress-port-bypass probe** — guest tries to dial
`<bundle-ip>:9099` (egress's port). TSI permits the IP but
the bundle's egress daemon binds `127.0.0.1` inside its
container, so the connect refuses at the socket level. The
bind-address mitigation is what closes TSI's port-granularity
gap.
Gated on macOS/Linux + smolvm + docker + not GITEA_ACTIONS — the
runner can't host libkrun-backed VMs."""
from __future__ import annotations
import os
import platform
import shutil
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available
from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker
_AGENT_PROMPT = "You are demo. Be brief."
def _minimal_manifest() -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"egress": {
"routes": [
{"host": "example.com"},
],
},
},
},
"agents": {
"demo": {
"skills": [],
"prompt": _AGENT_PROMPT,
"bottle": "dev",
},
},
})
@skip_unless_docker()
@unittest.skipUnless(
platform.system() in ("Darwin", "Linux"),
"smolvm requires macOS or Linux",
)
@unittest.skipUnless(
_smolvm_available(),
"smolvm not on PATH; install via "
"curl -sSL https://smolmachines.com/install.sh | sh",
)
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: cannot host libkrun-backed VMs",
)
class TestSmolmachinesLaunch(unittest.TestCase):
"""The full smoke + the two acceptance probes share one
bottle bringup to amortize the ~10s cold-start cost across
three assertions."""
@classmethod
def setUpClass(cls) -> None:
cls.stage = Path(tempfile.mkdtemp(prefix="cb-smol-launch."))
os.environ["BOT_BOTTLE_BACKEND"] = "smolmachines"
backend = get_bottle_backend()
spec = BottleSpec(
manifest=_minimal_manifest(),
agent_name="demo",
copy_cwd=False,
user_cwd=str(cls.stage),
)
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
cls._launch = backend.launch(cls.plan)
cls.bottle = cls._launch.__enter__()
@classmethod
def tearDownClass(cls) -> None:
try:
cls._launch.__exit__(None, None, None)
finally:
shutil.rmtree(cls.stage, ignore_errors=True)
os.environ.pop("BOT_BOTTLE_BACKEND", None)
def test_smoke_exec_echo(self):
# The plumbing-verifies-end-to-end smoke: a shell command
# round-trips through smolvm machine exec.
r = self.bottle.exec("echo hello-from-vm")
self.assertEqual(0, r.returncode, msg=r.stderr)
self.assertIn("hello-from-vm", r.stdout)
def test_localhost_reach_probe(self):
# Agent dials a 127.0.0.1 service on the host. TSI's
# allowlist contains only <bundle-ip>/32, so this must
# refuse. We use a port unlikely to be bound on the host
# (high-numbered) so we're confirming TSI refusal, not
# just "no service listening."
r = self.bottle.exec(
"curl -s --show-error --max-time 3 http://127.0.0.1:9 2>&1 || true"
)
# `curl` to a denied destination produces a connect error.
# The exact phrasing varies by curl version; we assert
# the response is NOT the body of any real service.
self.assertNotIn("hello-from-vm", r.stdout)
self.assertTrue(
"refused" in r.stdout.lower()
or "timed out" in r.stdout.lower()
or "unreachable" in r.stdout.lower()
or "failed" in r.stdout.lower(),
f"expected a connect-refusal message; got: {r.stdout!r}",
)
def test_egress_proxy_reachable_through_tsi_loopback_alias(self):
r = self.bottle.exec(
"printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\""
)
self.assertEqual(0, r.returncode, msg=r.stderr)
proxies = [line.strip() for line in r.stdout.splitlines()]
self.assertEqual(2, len(proxies), proxies)
self.assertEqual(proxies[0], proxies[1], proxies)
# macOS: proxy binds to the per-bottle loopback alias (127.x.x.x) so
# TSI can intercept guest connections to it. Linux: the guest kernel
# routes 127.0.0.0/8 to its own loopback (TSI never sees those), so
# the proxy instead binds to the per-bottle bridge gateway (192.168.x.1)
# which routes via eth0 and is intercepted by TSI normally.
self.assertRegex(
proxies[0], r"^http://\d+\.\d+\.\d+\.\d+:\d+$",
"expected proxy URL to be an http://IP:port address",
)
r = self.bottle.exec(
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
)
self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout)
self.assertIn("OK", r.stdout)
def test_direct_egress_bypass_without_proxy_fails(self):
r = self.bottle.exec(
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
"curl -s --show-error --max-time 5 https://example.com 2>&1 || true"
)
self.assertTrue(
"refused" in r.stdout.lower()
or "timed out" in r.stdout.lower()
or "unreachable" in r.stdout.lower()
or "failed" in r.stdout.lower()
or "could not resolve" in r.stdout.lower()
or "connection reset" in r.stdout.lower(),
f"expected direct egress to fail; got: {r.stdout!r}",
)
def test_non_allowlisted_host_fails_through_proxy(self):
r = self.bottle.exec(
"curl -s --show-error --max-time 10 https://iana.org 2>&1 || true"
)
self.assertTrue(
"403" in r.stdout
or "502" in r.stdout
or "blocked" in r.stdout.lower()
or "not allowed" in r.stdout.lower()
or "not in the bottle's egress.routes allowlist" in r.stdout.lower()
or "forbidden" in r.stdout.lower()
or "failed" in r.stdout.lower(),
f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}",
)
def test_prompt_file_lands_in_guest(self):
# provision_prompt copies the host-side prompt.txt into the
# guest at /home/node/.bot-bottle-prompt.txt. The content
# must match what the manifest declared so claude-code's
# --append-system-prompt-file reads the right text.
r = self.bottle.exec("cat /home/node/.bot-bottle-prompt.txt")
self.assertEqual(0, r.returncode, msg=r.stderr)
self.assertEqual(_AGENT_PROMPT, r.stdout.rstrip("\n"))
def test_egress_port_bypass_probe(self):
# Agent dials <bundle-ip>:9099 (egress's port). TSI
# permits the IP, but egress will bind 127.0.0.1:9099
# inside the bundle in chunk 3, so the connect refuses
# at the socket level. NOTE: in chunk 2d the bundle's
# daemons aren't running (daemons_csv=""), so nothing
# is listening on :9099 anyway — this test asserts the
# connect fails, which is the property chunk 3 will
# preserve once egress is actually running.
r = self.bottle.exec(
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 "
"2>&1 || true"
)
self.assertTrue(
"refused" in r.stdout.lower()
or "timed out" in r.stdout.lower()
or "unreachable" in r.stdout.lower()
or "failed" in r.stdout.lower(),
f"expected egress port refusal; got: {r.stdout!r}",
)
if __name__ == "__main__":
unittest.main()
@@ -1,64 +0,0 @@
"""Integration: PRD 0023 chunk 2b — smolvm subprocess wrapper
exercised against the real binary.
The full machine-lifecycle round trip (create → start → exec →
delete) is gated behind macOS/Linux platform check and lives
in chunk 2d's smoke. This file just verifies `is_available()`
correctly reports presence and `_smolvm()` can run a no-op
subcommand without errors — enough to flag wrapper drift if
smolvm's flag parser changes shape across versions."""
from __future__ import annotations
import os
import platform
import subprocess
import unittest
from bot_bottle.backend.smolmachines.smolvm import is_available
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: smolvm not installed on the runner",
)
@unittest.skipUnless(
platform.system() in ("Darwin", "Linux"),
"smolvm requires macOS or Linux",
)
@unittest.skipUnless(
is_available(),
"smolvm not on PATH; install via "
"curl -sSL https://smolmachines.com/install.sh | sh",
)
class TestSmolvmSmoke(unittest.TestCase):
def test_smolvm_help_responds(self):
# `smolvm --help` exits 0 (per `smolvm machine --help`
# convention) — verifies the binary launches and the
# top-level parser is intact.
r = subprocess.run(
["smolvm", "--help"],
capture_output=True, text=True, check=False,
)
# Either exit-code 0 (clean) or 1 (some CLIs return 1
# from --help by convention; smolvm 0.8.0 does this). The
# point is the binary runs and emits help text.
self.assertIn("smolvm", r.stdout)
self.assertIn("machine", r.stdout)
def test_machine_ls_empty_returns_json_array(self):
# `machine ls --json` is the contract chunk 4's
# list_active wires to. Lock in that the JSON shape is
# parseable now so chunk 4 doesn't surprise us.
import json
r = subprocess.run(
["smolvm", "machine", "ls", "--json"],
capture_output=True, text=True, check=False,
)
self.assertEqual(0, r.returncode, r.stderr)
parsed = json.loads(r.stdout)
self.assertIsInstance(parsed, list)
if __name__ == "__main__":
unittest.main()