From 43a5700ae61ca59464069f42ca9e4774be92878c Mon Sep 17 00:00:00 2001 From: didericis Date: Sat, 6 Jun 2026 16:03:17 -0400 Subject: [PATCH 1/8] docs(prd): PRD 0055 - promote smolmachines to default backend --- docs/prds/0055-smolmachines-default.md | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/prds/0055-smolmachines-default.md diff --git a/docs/prds/0055-smolmachines-default.md b/docs/prds/0055-smolmachines-default.md new file mode 100644 index 0000000..1b82fcc --- /dev/null +++ b/docs/prds/0055-smolmachines-default.md @@ -0,0 +1,79 @@ +# PRD 0055: Promote smolmachines to default backend; convert Docker to example-only + +- **Status:** Draft +- **Author:** didericis +- **Created:** 2026-06-06 +- **Issue:** #206 + +## Summary + +Make smolmachines the default bot-bottle backend and demote Docker to an example-only configuration. This closes the DNS sinkhole gap that exists in the Docker backend: the mitmproxy egress addon intercepts HTTP(S) but cannot see raw UDP port-53 DNS queries, so an agent can exfiltrate data via DNS tunnelling without the egress guard seeing it. The smolmachines backend eliminates this gap at the VMM layer — DNS filtering is built in and the agent container cannot bypass it. + +## Problem + +The current default backend is Docker. The egress addon (PRDs 0052/0053) intercepts HTTPS and scans request/response surfaces, but it is an HTTP proxy: raw UDP/TCP port-53 DNS queries go to the OS resolver and never pass through it. An agent can encode secrets as base32 or hex subdomains in a DNS query (`.attacker.com`) and exfiltrate them silently. + +The smolmachines backend already solves this: its Transport Socket Interface (TSI) enforces a CIDR allowlist at the VMM layer, and DNS is handled via vsock port 6002 — the guest's `/etc/resolv.conf` points at `127.0.0.1`, and a guest-side DNS proxy tunnels queries over vsock to the host, which returns NXDOMAIN for anything not on the allowlist. The agent cannot bypass this by hardcoding IPs or by configuring an alternate resolver, because both mechanisms are enforced below the guest OS. + +Docker has no equivalent. Adding dnsmasq to the Docker backend would close the gap at some cost (dnsmasq sidecar, iptables `NET_ADMIN`, per-launch config generation), but it is the wrong direction if smolmachines supersedes Docker anyway. + +## Goals / Success Criteria + +- `BOT_BOTTLE_BACKEND` defaults to `smolmachines` when not set. +- The existing Docker backend remains functional (not removed) but is no longer the default and is documented as legacy/example-only. +- Example bottles (`examples/bottles/`) reference smolmachines, not Docker. +- `AGENTS.md` documents the backend choice and the DNS gap closure. +- Existing Docker-backed integration tests continue to pass; they select Docker explicitly via `BOT_BOTTLE_BACKEND=docker` rather than relying on the default. + +## Non-goals + +- Removing the Docker backend or its tests. +- Implementing a dnsmasq layer for the Docker backend (closed by this change; not needed on the default path). +- Iptables / `NET_ADMIN` work for Docker (deferred). +- Subdomain-depth filtering for allowlisted zones (documented residual gap; tracked separately per the issue). + +## Design + +### Default backend change + +`bot_bottle/backend/__init__.py`, line ~440: + +```python +# Before +resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "docker" + +# After +resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines" +``` + +### DNS gap closure (how smolmachines handles it) + +When the smolmachines backend launches an agent VM: + +1. The VM's network device uses TSI (`--allow-host` / `--allow-cidr` flags), which enforces a CIDR allowlist at the VMM layer. The guest cannot dial IPs outside the allowlist even with raw sockets. +2. The guest's `/etc/resolv.conf` is set to `127.0.0.1`; a guest-side DNS proxy relays queries over vsock port 6002 to the host. +3. The host-side DNS filter returns NXDOMAIN for any hostname not in the allowlist derived from `egress.routes` in the bottle manifest. + +This means DNS exfiltration via unknown subdomains is blocked by NXDOMAIN before the query leaves the host, and even if the agent hardcoded the IP of an attacker-controlled server, TSI would drop the packet at the VMM layer. + +**Residual gap:** if the attacker controls a subdomain of an allowlisted zone (e.g., a legitimate zone like `api.anthropic.com` that the attacker can inject into via a separate compromise), DNS queries for that subdomain would be forwarded. This is accepted and documented. + +### Example bottles + +Update `examples/bottles/dev.md` and `examples/bottles/claude.md` to remove Docker-specific notes and reference smolmachines as the runtime. + +### Integration test migration + +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 + +- **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? + +## References + +- `docs/research/smolmachines-as-vm-backend.md` — smolmachines evaluation +- `docs/research/network-egress-guard.md` — Approach 4 (DNS-based egress control) +- `docs/research/secret-exfil-tripwire-encodings.md` — DNS exfil discussion +- PRD 0052, PRD 0053 — egress DLP addon (HTTP-level; partial mitigation only) -- 2.52.0 From 39b0c4f720eabf30b163c6f2f474463ab8825527 Mon Sep 17 00:00:00 2001 From: didericis Date: Sat, 6 Jun 2026 16:26:01 -0400 Subject: [PATCH 2/8] =?UTF-8?q?docs(prd):=20renumber=20PRD=200055=20?= =?UTF-8?q?=E2=86=92=200058=20(0055=20slot=20taken=20by=20extended-outboun?= =?UTF-8?q?d-scan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...055-smolmachines-default.md => 0058-smolmachines-default.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/prds/{0055-smolmachines-default.md => 0058-smolmachines-default.md} (98%) diff --git a/docs/prds/0055-smolmachines-default.md b/docs/prds/0058-smolmachines-default.md similarity index 98% rename from docs/prds/0055-smolmachines-default.md rename to docs/prds/0058-smolmachines-default.md index 1b82fcc..d89bcb5 100644 --- a/docs/prds/0055-smolmachines-default.md +++ b/docs/prds/0058-smolmachines-default.md @@ -1,4 +1,4 @@ -# PRD 0055: Promote smolmachines to default backend; convert Docker to example-only +# PRD 0058: Promote smolmachines to default backend; convert Docker to example-only - **Status:** Draft - **Author:** didericis -- 2.52.0 From aff042855a5484af5c87b1314d4ccba8463d9bcf Mon Sep 17 00:00:00 2001 From: didericis Date: Sat, 6 Jun 2026 22:10:13 -0400 Subject: [PATCH 3/8] ci(prd): rename PRD to prd-new placeholder per new convention --- ...-smolmachines-default.md => prd-new-smolmachines-default.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/prds/{0058-smolmachines-default.md => prd-new-smolmachines-default.md} (98%) diff --git a/docs/prds/0058-smolmachines-default.md b/docs/prds/prd-new-smolmachines-default.md similarity index 98% rename from docs/prds/0058-smolmachines-default.md rename to docs/prds/prd-new-smolmachines-default.md index d89bcb5..c4bb3c6 100644 --- a/docs/prds/0058-smolmachines-default.md +++ b/docs/prds/prd-new-smolmachines-default.md @@ -1,4 +1,4 @@ -# PRD 0058: Promote smolmachines to default backend; convert Docker to example-only +# PRD prd-new: Promote smolmachines to default backend; convert Docker to example-only - **Status:** Draft - **Author:** didericis -- 2.52.0 From fabcd026afbe51b8fd5db001d10531884c0f59a7 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 9 Jun 2026 03:14:58 +0000 Subject: [PATCH 4/8] test(smolmachines): verify TSI egress proxy path --- tests/integration/test_smolmachines_launch.py | 75 +++++++++++++++++-- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 513c11f..07cbeac 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -2,16 +2,17 @@ round trip + the acceptance probes. The smoke confirms the launch flow (per-bottle docker bridge → -sidecar bundle with pinned IP → smolvm guest with TSI allowlist → -exec) plumbs together end to end. The two probes confirm the +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 `/32` - allowlist must refuse the connect. (PRD 0023's first draft - worried about `--outbound-localhost-only` opening the whole - `127.0.0.0/8`; with `--allow-cidr /32` instead, - the gap closes.) + 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 `:9099` (egress's port). TSI permits the IP but @@ -43,7 +44,15 @@ _AGENT_PROMPT = "You are demo. Be brief." def _minimal_manifest() -> Manifest: return Manifest.from_json_obj({ - "bottles": {"dev": {}}, + "bottles": { + "dev": { + "egress": { + "routes": [ + {"host": "example.com"}, + ], + }, + }, + }, "agents": { "demo": { "skills": [], @@ -124,6 +133,56 @@ class TestSmolmachinesLaunch(unittest.TestCase): f"expected a connect-refusal message; got: {r.stdout!r}", ) + def test_egress_proxy_reachable_through_tsi_loopback_alias(self): + self.assertTrue( + self.plan.agent_proxy_url.startswith("http://127."), + self.plan.agent_proxy_url, + ) + 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( + [self.plan.agent_proxy_url, self.plan.agent_proxy_url], + proxies, + ) + + 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 "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 -- 2.52.0 From cc1d986a7494dbe99edcc715654db5388b7ba212 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 8 Jun 2026 23:22:47 -0400 Subject: [PATCH 5/8] test: fix smolmachines proxy assertions --- tests/integration/test_smolmachines_launch.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 07cbeac..706eb3b 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -134,19 +134,14 @@ class TestSmolmachinesLaunch(unittest.TestCase): ) def test_egress_proxy_reachable_through_tsi_loopback_alias(self): - self.assertTrue( - self.plan.agent_proxy_url.startswith("http://127."), - self.plan.agent_proxy_url, - ) 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( - [self.plan.agent_proxy_url, self.plan.agent_proxy_url], - proxies, - ) + self.assertEqual(2, len(proxies), proxies) + self.assertEqual(proxies[0], proxies[1], proxies) + self.assertTrue(proxies[0].startswith("http://127."), proxies[0]) r = self.bottle.exec( "curl -fsS --max-time 20 https://example.com >/dev/null && echo OK" @@ -178,6 +173,7 @@ class TestSmolmachinesLaunch(unittest.TestCase): 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}", -- 2.52.0 From 1bebb7467faaa0d0ade202b0e0dd9b2866c8433e Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 9 Jun 2026 03:27:31 +0000 Subject: [PATCH 6/8] feat(backend): default to smolmachines --- AGENTS.md | 16 ++++++++++------ README.md | 15 +++++++++++---- bot_bottle/backend/__init__.py | 6 +++--- bot_bottle/backend/docker/backend.py | 2 +- bot_bottle/backend/smolmachines/util.py | 4 +++- bot_bottle/cli/start.py | 4 ++-- docs/prds/prd-new-smolmachines-default.md | 6 +++--- examples/bottles/claude.md | 4 +++- examples/bottles/dev.md | 3 ++- tests/integration/test_sandbox_escape.py | 7 ++++--- tests/integration/test_sidecar_bundle_compose.py | 2 +- tests/unit/test_backend_selection.py | 4 ++-- tests/unit/test_cli_start_backend_flag.py | 2 +- tests/unit/test_smolmachines_util.py | 1 + 14 files changed, 47 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 15f7c02..a0b74be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 10b8b8b..3460598 100644 --- a/README.md +++ b/README.md @@ -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 ` on hosts where smolvm is not installed. ```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 28dbc7f..501379d 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -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=` 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}") diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index 2ebcf42..23c7d1e 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -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" diff --git a/bot_bottle/backend/smolmachines/util.py b/bot_bottle/backend/smolmachines/util.py index b28f451..cadb8a7 100644 --- a/bot_bottle/backend/smolmachines/util.py +++ b/bot_bottle/backend/smolmachines/util.py @@ -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." ) diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 13f91cd..36b4371 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 '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 diff --git a/docs/prds/prd-new-smolmachines-default.md b/docs/prds/prd-new-smolmachines-default.md index c4bb3c6..be9372b 100644 --- a/docs/prds/prd-new-smolmachines-default.md +++ b/docs/prds/prd-new-smolmachines-default.md @@ -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 diff --git a/examples/bottles/claude.md b/examples/bottles/claude.md index a47037a..b7e5d8f 100644 --- a/examples/bottles/claude.md +++ b/examples/bottles/claude.md @@ -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. diff --git a/examples/bottles/dev.md b/examples/bottles/dev.md index 8e9b8aa..93b9d9c 100644 --- a/examples/bottles/dev.md +++ b/examples/bottles/dev.md @@ -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. diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index b547539..1614203 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -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 diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py index 2051ea3..4d6b9cd 100644 --- a/tests/integration/test_sidecar_bundle_compose.py +++ b/tests/integration/test_sidecar_bundle_compose.py @@ -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", diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index 3df9003..61aeb89 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -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")): diff --git a/tests/unit/test_cli_start_backend_flag.py b/tests/unit/test_cli_start_backend_flag.py index b3d3529..24933d4 100644 --- a/tests/unit/test_cli_start_backend_flag.py +++ b/tests/unit/test_cli_start_backend_flag.py @@ -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) diff --git a/tests/unit/test_smolmachines_util.py b/tests/unit/test_smolmachines_util.py index 89f31a0..436cce6 100644 --- a/tests/unit/test_smolmachines_util.py +++ b/tests/unit/test_smolmachines_util.py @@ -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__": -- 2.52.0 From 17fc44d0d8669b0efe7d1ebd15554ca3c1c30a9c Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 9 Jun 2026 03:27:58 +0000 Subject: [PATCH 7/8] complete(prd): mark smolmachines default active --- docs/prds/prd-new-smolmachines-default.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/prds/prd-new-smolmachines-default.md b/docs/prds/prd-new-smolmachines-default.md index be9372b..6d8c92f 100644 --- a/docs/prds/prd-new-smolmachines-default.md +++ b/docs/prds/prd-new-smolmachines-default.md @@ -1,6 +1,6 @@ # PRD prd-new: Promote smolmachines to default backend; convert Docker to example-only -- **Status:** Draft +- **Status:** Active - **Author:** didericis - **Created:** 2026-06-06 - **Issue:** #206 -- 2.52.0 From e6040fc8248e3ff099489c55127fa0755cefa43f Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 9 Jun 2026 03:31:26 +0000 Subject: [PATCH 8/8] fix(start): skip backend selector --- bot_bottle/cli/start.py | 7 ----- tests/unit/test_cli_start_selector.py | 42 ++++++++++++--------------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 36b4371..280074f 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -72,13 +72,6 @@ def cmd_start(argv: list[str]) -> int: return 0 backend_name: str | None = args.backend - if backend_name is None and "BOT_BOTTLE_BACKEND" not in os.environ: - backend_name = tui.filter_select( - list(known_backend_names()), - title="Select backend", - ) - if backend_name is None: - return 0 label, color = tui.name_color_modal(default_label=agent_name) diff --git a/tests/unit/test_cli_start_selector.py b/tests/unit/test_cli_start_selector.py index f224f47..d34ee0e 100644 --- a/tests/unit/test_cli_start_selector.py +++ b/tests/unit/test_cli_start_selector.py @@ -1,7 +1,7 @@ """Unit: cmd_start selector dispatch (PRD 0051). -Tests that cmd_start calls filter_select when name / backend are absent, -skips them when both are explicit, and returns 0 on cancel. +Tests that cmd_start calls filter_select only when the agent name is +absent, skips it when the agent is explicit, and returns 0 on cancel. All actual launch work is stubbed so no container is created. """ @@ -45,7 +45,8 @@ class TestCmdStartSelector(unittest.TestCase): self._tui_patch = patch.object(tui_mod, "filter_select") self._tui_mock = self._tui_patch.start() - # Ensure BOT_BOTTLE_BACKEND is absent so the backend picker fires. + # Ensure BOT_BOTTLE_BACKEND is absent so omitted --backend + # flows through to the resolver default. self._env_patch = patch.dict(os.environ, {}, clear=False) self._env_patch.start() os.environ.pop("BOT_BOTTLE_BACKEND", None) @@ -89,22 +90,16 @@ class TestCmdStartSelector(unittest.TestCase): self._launch_mock.assert_not_called() # ------------------------------------------------------------------ - # Agent explicit, backend absent → backend picker fires + # Agent explicit, backend absent → no picker # ------------------------------------------------------------------ - def test_backend_absent_shows_backend_picker(self): - self._tui_mock.return_value = "docker" + def test_backend_absent_uses_default_without_picker(self): rc = start_mod.cmd_start(["researcher"]) self.assertEqual(0, rc) - self._tui_mock.assert_called_once() - call_kwargs = self._tui_mock.call_args - self.assertIn("backend", call_kwargs[1]["title"].lower()) - - def test_backend_picker_cancel_returns_0(self): - self._tui_mock.return_value = None - rc = start_mod.cmd_start(["researcher"]) - self.assertEqual(0, rc) - self._launch_mock.assert_not_called() + self._tui_mock.assert_not_called() + self._launch_mock.assert_called_once() + _, kwargs = self._launch_mock.call_args + self.assertIsNone(kwargs["backend_name"]) def test_bot_bottle_backend_env_skips_backend_picker(self): os.environ["BOT_BOTTLE_BACKEND"] = "docker" @@ -116,18 +111,19 @@ class TestCmdStartSelector(unittest.TestCase): self._tui_mock.assert_not_called() # ------------------------------------------------------------------ - # Both absent → agent picker then backend picker + # Both absent → only agent picker # ------------------------------------------------------------------ - def test_both_absent_shows_both_pickers_in_order(self): - self._tui_mock.side_effect = ["researcher", "docker"] + def test_both_absent_shows_only_agent_picker(self): + self._tui_mock.return_value = "researcher" rc = start_mod.cmd_start([]) self.assertEqual(0, rc) - self.assertEqual(2, self._tui_mock.call_count) - first_title = self._tui_mock.call_args_list[0][1]["title"].lower() - second_title = self._tui_mock.call_args_list[1][1]["title"].lower() - self.assertIn("agent", first_title) - self.assertIn("backend", second_title) + self._tui_mock.assert_called_once() + title = self._tui_mock.call_args[1]["title"].lower() + self.assertIn("agent", title) + self._launch_mock.assert_called_once() + _, kwargs = self._launch_mock.call_args + self.assertIsNone(kwargs["backend_name"]) def test_both_absent_agent_cancel_skips_backend_picker(self): self._tui_mock.side_effect = [None] -- 2.52.0