diff --git a/AGENTS.md b/AGENTS.md index ff7ca05..6920191 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,11 +9,11 @@ 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 on compatible macOS hosts is macos-container: agents and sidecar bundles run through Apple's `container` CLI without -requiring Docker. The smolmachines backend remains available with -`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`. +requiring Docker. On KVM-capable Linux hosts the default is firecracker: +agents run in a Firecracker microVM reached over SSH on a point-to-point +TAP, while the sidecar bundle still uses Docker. The legacy Docker +backend remains available with `BOT_BOTTLE_BACKEND=docker` or +`--backend=docker`. ## Goals diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index 89b33b3..e581856 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -24,7 +24,7 @@ # Exposed ports inside the container: # 9099 egress (mitmproxy, agent-facing HTTPS proxy) # 9418 git-gate (git-daemon) -# 9420 git-gate smart HTTP (smolmachines agent-facing transport) +# 9420 git-gate smart HTTP (VM-backend agent-facing transport) # 9100 supervise (MCP HTTP) # Stage 1: gitleaks binary. The upstream gitleaks image is alpine diff --git a/README.md b/README.md index 6f24346..7cad616 100644 --- a/README.md +++ b/README.md @@ -25,14 +25,14 @@ - **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. - **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. -- **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. Runs on macOS (Hypervisor.framework) and Linux (KVM, `/dev/kvm`). -- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. +- **Firecracker backend (Linux default when available)** — runs the agent in a KVM Firecracker microVM reached over SSH on a point-to-point TAP, with the sidecar bundle in Docker. A dedicated, fail-closed `nftables` table isolates the guest, closing the raw DNS/IP exfiltration gap that exists in the legacy Docker backend. Requires KVM (`/dev/kvm`) and a one-time privileged network-pool setup. +- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container or KVM via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. ## Architecture 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 Firecracker backend, a bottle is an agent microVM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars over a per-bottle point-to-point TAP link; a dedicated fail-closed `nftables` table (`inet bot_bottle_fc`) confines the guest to that link, so nothing leaves the box except through the sidecars. The TAP pool and nft table are provisioned once (root); per-launch needs no privilege. 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. @@ -71,25 +71,24 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi ## Quickstart -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` (macOS or Linux). 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`. +On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the sidecar bundle plus the `firecracker` binary and KVM. 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 ` on hosts where Apple Container is not installed and Docker is the desired backend. +Use `BOT_BOTTLE_BACKEND=docker ./cli.py start ` on hosts where neither Apple Container nor KVM is available and Docker is the desired backend. -### smolmachines on Linux +### Firecracker on Linux -The smolmachines backend runs on Linux as well as macOS. On Linux, `smolvm`/libkrun use KVM, so the host needs: +On Linux, a KVM-capable host defaults to the Firecracker backend. It needs: - **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing. -- **`smolvm`** on `PATH`: `curl -sSL https://smolmachines.com/install.sh | sh`. -- **Docker** for the sidecar bundle and image build, same as macOS. - -Per-bottle isolation works the same as macOS without any `ifconfig`/sudo step — all of `127.0.0.0/8` is already loopback on Linux, so each bottle's sidecar bundle is published on its own `127.0.0.` and TSI's allowlist is scoped to that `/32`. +- **`firecracker`** on `PATH`: grab a release from . Start flows print this pointer when the binary is missing. +- **Docker** for the sidecar bundle and image build. +- **A one-time privileged network setup** — the per-bottle TAP pool plus the fail-closed `nftables` isolation table. Run `./cli.py firecracker setup` for the host-appropriate command (a declarative module on NixOS, a `sudo` script elsewhere); `./cli.py firecracker status` reports what's present. ```sh -BOT_BOTTLE_BACKEND=smolmachines ./cli.py start +BOT_BOTTLE_BACKEND=firecracker ./cli.py start ``` -> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. If you run bottles from a Gitea Actions runner, use a `host`-label runner so Docker, `smolvm`, and `/dev/kvm` are all reachable from the job. `smolvm` isn't in nixpkgs — install the release binary (pin the version) and put it on the runner's `PATH`. +> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. Apply the network-pool module from `./cli.py firecracker setup` and `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`. ```sh ./cli.py start # builds the image on first run, drops you into claude diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index feb8a50..398f239 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -227,9 +227,9 @@ class AgentProvider(ABC): from .backend.util import AGENT_CA_PATH, log_ca_fingerprint, select_ca_cert from .log import die cert_host_path, label = select_ca_cert(plan.egress_plan) - # Ensure the target directory exists. smolvm's pack step may not - # preserve the empty /usr/local/share/ca-certificates/ directory - # on Linux; mkdir -p is idempotent and safe for all backends. + # Ensure the target directory exists. A backend's rootfs build + # may not preserve the empty /usr/local/share/ca-certificates/ + # directory; mkdir -p is idempotent and safe for all backends. bottle.exec("mkdir -p /usr/local/share/ca-certificates", user="root") bottle.cp_in(str(cert_host_path), AGENT_CA_PATH) r = bottle.exec( diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 41559c5..9080681 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -26,9 +26,9 @@ backend exposes five methods: Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND (env var). When neither is set, compatible macOS hosts default to -`macos-container`; Linux hosts with Firecracker + KVM default to -`firecracker`; otherwise `smolmachines`. Per PRD 0003 the manifest -does not carry a backend field; the host picks. +`macos-container`; Linux hosts with KVM default to `firecracker`; +otherwise `docker`. Per PRD 0003 the manifest does not carry a +backend field; the host picks. """ from __future__ import annotations @@ -98,14 +98,14 @@ class BottlePlan(ABC): @property def git_gate_insteadof_host(self) -> str: """Host (and optional port) used in git-gate insteadOf URLs. - Docker uses the compose-network DNS alias; smolmachines - overrides with a loopback IP:port since TSI has no DNS.""" + Docker uses the compose-network DNS alias; VM backends may + override with an IP:port when the guest has no DNS.""" return "git-gate" @property def git_gate_insteadof_scheme(self) -> str: """URL scheme for git-gate insteadOf rewrites. 'git' for - Docker (git daemon); 'http' for smolmachines (HTTP proxy + Docker (git daemon); VM backends may override (e.g. 'http' over a published host port).""" return "git" egress_plan: EgressPlan @@ -183,8 +183,8 @@ class BottleCleanupPlan(ABC): class ExecResult: """Captured result of `Bottle.exec`. Backend-neutral: the Docker impl populates it from a `subprocess.CompletedProcess`, but a - future fly/smolmachines backend could populate it from any source - that produces a returncode + captured streams.""" + VM backend could populate it from any source that produces a + returncode + captured streams.""" returncode: int stdout: str @@ -202,7 +202,7 @@ class ActiveAgent: of sidecar daemons currently up for this bottle (`egress`, `git-gate`, `supervise`); the dashboard uses it to gate edit verbs. `backend_name` is the matching key in - `_BACKENDS` (`docker` / `smolmachines` / `macos-container`) — used by the active- + `_BACKENDS` (`docker` / `firecracker` / `macos-container`) — used by the active- list rendering to disambiguate and by the dashboard's re-attach path.""" @@ -507,7 +507,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): MCP entry inside the guest. Default returns "" so backends without supervise support - don't have to implement it. Docker and smolmachines override.""" + don't have to implement it. Docker and firecracker override.""" del plan return "" @@ -524,19 +524,19 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): def enumerate_active(self) -> Sequence[ActiveAgent]: """Return every currently-running agent on this backend. Empty when none. Backend-specific: docker queries `docker - compose ls`; smolmachines queries `smolvm machine ls --json` - + cross-references its bundle container.""" + compose ls`; firecracker cross-references its running sidecar + containers against per-bottle metadata.""" @classmethod @abstractmethod def is_available(cls) -> bool: """Whether this backend's runtime prerequisites are satisfied - on the current host. Docker → `docker` on PATH; smolmachines - → `smolvm` on PATH. Used by the cross-backend + on the current host. Docker → `docker` on PATH; firecracker → + Linux + KVM. Used by the cross-backend `enumerate_active_agents` / `cmd_cleanup` to skip backends the operator hasn't installed, so a docker-only host doesn't fail when `cli.py list active` walks past - smolmachines.""" + firecracker.""" # Import concrete backend classes AFTER the base types are defined, so @@ -545,7 +545,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position from .firecracker import FirecrackerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position -from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position # Freezer is imported after the backend classes for the same reason: # Freezer.commit_slug constructs ActiveAgent, which must be fully @@ -561,7 +560,6 @@ _BACKENDS: dict[str, BottleBackend[Any, Any]] = { "docker": DockerBottleBackend(), "firecracker": FirecrackerBottleBackend(), "macos-container": MacosContainerBottleBackend(), - "smolmachines": SmolmachinesBottleBackend(), } @@ -574,7 +572,8 @@ def get_bottle_backend( 1. explicit arg (CLI `--backend=` passes through here) 2. BOT_BOTTLE_BACKEND env var 3. `macos-container` on compatible macOS hosts - 4. default `smolmachines` + 4. `firecracker` on KVM-capable Linux hosts + 5. default `docker` Dies with a pointer at the known backends if the chosen name isn't implemented.""" @@ -588,9 +587,13 @@ def get_bottle_backend( def _default_backend_name() -> str: if has_backend("macos-container"): return "macos-container" - if has_backend("firecracker"): + # A KVM-capable Linux host defaults to firecracker even when the + # `firecracker` binary isn't installed yet: selecting it here routes + # start through firecracker's preflight, which prints an install + # pointer, instead of silently falling back to docker. + if FirecrackerBottleBackend.is_host_capable(): return "firecracker" - return "smolmachines" + return "docker" def known_backend_names() -> tuple[str, ...]: @@ -604,7 +607,7 @@ def has_backend(name: str) -> bool: """Whether the named backend's runtime prerequisites are available on the current host. Cross-backend callers (list, cleanup) skip unavailable backends so a docker-only host - doesn't fail when the smolmachines backend isn't installed, + doesn't fail when the firecracker backend isn't usable, and vice versa. Returns False for unknown names so callers can pass diff --git a/bot_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py index 079de35..254c63f 100644 --- a/bot_bottle/backend/docker/cleanup.py +++ b/bot_bottle/backend/docker/cleanup.py @@ -18,8 +18,7 @@ scan, just as a fallback bucket alongside the project list. `cleanup` removes everything in the plan. -Active-agent enumeration lives in `backend/docker/enumerate.py` -(mirror of `backend/smolmachines/enumerate.py`). +Active-agent enumeration lives in `backend/docker/enumerate.py`. """ from __future__ import annotations @@ -93,8 +92,8 @@ def _list_orphan_state_dirs( `protected_identities` is the set of slugs that are live in ANY backend — used so this docker-side check doesn't reap a - running smolmachines bottle's state dir (the layout is shared - across both backends).""" + running non-docker bottle's state dir (the layout is shared + across backends).""" state_root = _supervise.bot_bottle_root() / "state" if not state_root.is_dir(): return [] @@ -119,7 +118,7 @@ def prepare_cleanup() -> DockerBottleCleanupPlan: Pulls the union of live identities across backends via `enumerate_active_agents()` so the orphan-state-dir bucket - doesn't include slugs whose smolmachines VM is still up.""" + doesn't include slugs whose non-docker bottle is still up.""" docker_mod.require_docker() projects = list_compose_projects() project_set = set(projects) diff --git a/bot_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py index af12af3..0337d5a 100644 --- a/bot_bottle/backend/docker/enumerate.py +++ b/bot_bottle/backend/docker/enumerate.py @@ -1,7 +1,6 @@ """Active-agent enumeration for the docker backend. -Mirrors `backend/smolmachines/enumerate.py`: returns -`ActiveAgent` records the CLI `list active` command and the +Returns `ActiveAgent` records the CLI `list active` command and the dashboard agents pane consume. Empty when docker isn't reachable — gated by `has_backend('docker')` at the cross-backend caller so this module trusts that docker is available when called. diff --git a/bot_bottle/backend/docker/util.py b/bot_bottle/backend/docker/util.py index 8ac0339..6a4abdd 100644 --- a/bot_bottle/backend/docker/util.py +++ b/bot_bottle/backend/docker/util.py @@ -167,36 +167,6 @@ def commit_container(container_name: str, image_tag: str) -> None: info(f"committed {container_name!r} → {image_tag!r}") -def image_id(ref: str) -> str: - """Return the content-addressed image ID (e.g. - `sha256:abcd...`) for `ref`. The smolmachines backend keys its - `.smolmachine` artifact cache on this, so a Dockerfile change - that produces a new image automatically invalidates the cache.""" - r = subprocess.run( - ["docker", "image", "inspect", "--format", "{{.Id}}", ref], - capture_output=True, - text=True, - check=False, - ) - if r.returncode != 0: - die( - f"docker image inspect for {ref!r} failed: " - f"{(r.stderr or '').strip() or ''}" - ) - return r.stdout.strip() - - -def save(ref: str, output: str) -> None: - """`docker save REF -o OUTPUT`. Writes a tarball of the image - layers + manifest to the host path. Used by smolmachines - prepare to hand the agent image to a containerized crane that - pushes it to the ephemeral registry — bypassing the docker - daemon's `docker push` (which on Docker Desktop can't reach a - host-loopback registry and refuses plain-HTTP pushes to - non-loopback hosts).""" - subprocess.run(["docker", "save", ref, "-o", output], check=True) - - def _silent_run(cmd: Iterable[str]) -> int: return subprocess.run( list(cmd), diff --git a/bot_bottle/backend/firecracker/backend.py b/bot_bottle/backend/firecracker/backend.py index 8431dca..58d49f9 100644 --- a/bot_bottle/backend/firecracker/backend.py +++ b/bot_bottle/backend/firecracker/backend.py @@ -38,6 +38,14 @@ class FirecrackerBottleBackend( def is_available(cls) -> bool: return _util.is_available() + @classmethod + def is_host_capable(cls) -> bool: + """Linux + KVM, regardless of whether the `firecracker` binary + is installed. Drives default-backend selection so a capable host + without the binary still lands on firecracker and gets an + install pointer at launch.""" + return _util.is_host_capable() + def _preflight(self) -> None: _resolve_plan.preflight() diff --git a/bot_bottle/backend/firecracker/bottle.py b/bot_bottle/backend/firecracker/bottle.py index 3805dc9..aff4fcf 100644 --- a/bot_bottle/backend/firecracker/bottle.py +++ b/bot_bottle/backend/firecracker/bottle.py @@ -4,8 +4,7 @@ Every operation routes through SSH to the guest (dropbear, listening on the TAP link). `exec` pipes a script over stdin and captures output; `cp_in` uses scp; `exec_agent` uses `ssh -t` for an interactive PTY session. `ssh -t` forwards the host terminal's SIGWINCH to the remote -PTY natively, so — unlike the smolmachines backend — no resize bridge -is needed. +PTY natively, so no separate resize bridge is needed. Commands run as the image's `node` user via `runuser`, with HOME/USER/ PATH and the bottle env (HTTPS_PROXY at the sidecar, CA paths, …) set diff --git a/bot_bottle/backend/firecracker/enumerate.py b/bot_bottle/backend/firecracker/enumerate.py index b4a7f42..4c9e90d 100644 --- a/bot_bottle/backend/firecracker/enumerate.py +++ b/bot_bottle/backend/firecracker/enumerate.py @@ -28,8 +28,8 @@ def enumerate_active() -> list[ActiveAgent]: slug = name[len(_SIDECAR_PREFIX):] metadata = read_metadata(slug) if metadata is None or metadata.backend != "firecracker": - # Skip sidecars owned by another backend (docker/smolmachines - # share the container-name prefix). + # Skip sidecars owned by another backend (docker shares the + # container-name prefix). continue out.append(ActiveAgent( backend_name="firecracker", diff --git a/bot_bottle/backend/firecracker/util.py b/bot_bottle/backend/firecracker/util.py index 1b72dd6..4e251fa 100644 --- a/bot_bottle/backend/firecracker/util.py +++ b/bot_bottle/backend/firecracker/util.py @@ -63,16 +63,22 @@ def is_linux() -> bool: return platform.system() == "Linux" +def is_host_capable() -> bool: + """Whether this host *could* run firecracker — Linux with KVM — + regardless of whether the `firecracker` binary is installed. Used + for default-backend selection so a KVM Linux host that hasn't + installed firecracker yet still selects it and gets an install + pointer at launch (see `require_firecracker`), rather than silently + falling back to docker.""" + return is_linux() and os.path.exists(_KVM_DEVICE) + + def is_available() -> bool: """Cheap capability probe used by cross-backend enumeration — firecracker on PATH, on Linux, with KVM. Does not check the (operator-provisioned) kernel / pool, so an available-but-unset host still shows up and gets an actionable error at launch.""" - return ( - is_linux() - and shutil.which("firecracker") is not None - and os.path.exists(_KVM_DEVICE) - ) + return is_host_capable() and shutil.which("firecracker") is not None def require_firecracker() -> None: @@ -83,6 +89,7 @@ def require_firecracker() -> None: die("firecracker backend is only supported on Linux (KVM). " "On macOS use --backend=macos-container.") if shutil.which("firecracker") is None: + info("Firecracker is required but was not found on PATH.") info("Install: https://github.com/firecracker-microvm/firecracker/releases") die("firecracker not found on PATH") _require_kvm() diff --git a/bot_bottle/backend/freeze.py b/bot_bottle/backend/freeze.py index 90b394c..ec4f424 100644 --- a/bot_bottle/backend/freeze.py +++ b/bot_bottle/backend/freeze.py @@ -93,12 +93,8 @@ def get_freezer(backend_name: str) -> Freezer: if resolved == "firecracker": from .firecracker.freezer import FirecrackerFreezer return FirecrackerFreezer() - if resolved == "smolmachines": - from .smolmachines.freezer import SmolmachinesFreezer - return SmolmachinesFreezer() die( - f"commit is only supported for docker, macos-container, " - f"firecracker, and smolmachines; backend {backend_name!r} has " - f"no freezer" + f"commit is only supported for docker, macos-container, and " + f"firecracker; backend {backend_name!r} has no freezer" ) raise AssertionError("unreachable") diff --git a/bot_bottle/backend/print_util.py b/bot_bottle/backend/print_util.py index 4b4ec3d..5f18cdb 100644 --- a/bot_bottle/backend/print_util.py +++ b/bot_bottle/backend/print_util.py @@ -1,9 +1,8 @@ """Shared print helpers for BottlePlan.print implementations. -Lifts the multi-value label printer out of DockerBottlePlan so the -smolmachines backend (and any future backend) renders the same -two-column scannable preflight without duplicating the indent -math.""" +Lifts the multi-value label printer out of DockerBottlePlan so every +backend (and any future backend) renders the same two-column +scannable preflight without duplicating the indent math.""" from __future__ import annotations diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index d1ad0dc..7270d37 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -1,7 +1,7 @@ -"""Shared helpers used by both backends' resolve_plan steps. +"""Shared helpers used across backends' resolve_plan steps. Each helper owns one well-defined step of the per-bottle plan -resolution so docker and smolmachines don't repeat the same logic. +resolution so the backends don't repeat the same logic. Backend-specific steps (container names, env-file, per-bottle Dockerfile overrides, subnet allocation) stay in the backend's own resolve_plan.py. diff --git a/bot_bottle/backend/smolmachines/__init__.py b/bot_bottle/backend/smolmachines/__init__.py deleted file mode 100644 index 6b65870..0000000 --- a/bot_bottle/backend/smolmachines/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""smolmachines bottle backend (PRD 0023). - -Selectable via `BOT_BOTTLE_BACKEND=smolmachines`. Runs each -bottle inside a per-agent microVM (libkrun / Hypervisor.framework -on macOS) with a userspace gvproxy gateway as the egress -primitive. The sidecar bundle (PRD 0024) runs as a host-side -docker container reached only through gvproxy's port-forward list. - -Chunk 1 (this commit) ships the backend skeleton + Smolfile + -gvproxy renderers + preflight check. VM lifecycle, sidecar -bringup, and provisioning land in later chunks.""" - -from .backend import SmolmachinesBottleBackend # noqa: F401 - -__all__ = ["SmolmachinesBottleBackend"] diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py deleted file mode 100644 index f24be61..0000000 --- a/bot_bottle/backend/smolmachines/backend.py +++ /dev/null @@ -1,101 +0,0 @@ -"""SmolmachinesBottleBackend — the smolmachines implementation of -BottleBackend (PRD 0023). - -Per PRD 0050 the per-provider provisioning steps (prompt, skills, -the declarative provision-plan apply, supervise MCP registration) -live on the `AgentProvider` plugin under `bot_bottle/contrib/`. The -smolmachines backend only owns the steps that are about backend -infrastructure: CA install (no-op for now), workspace, git copy-in.""" - -from __future__ import annotations - -from contextlib import contextmanager -from pathlib import Path -from typing import Generator, Sequence - -from ...agent_provider import AgentProvisionPlan -from ...egress import EgressPlan -from ...env import ResolvedEnv -from ...git_gate import GitGatePlan -from ...supervise import SupervisePlan -from ...manifest import Manifest -from .. import ActiveAgent, BottleBackend, BottleSpec -from . import cleanup as _cleanup -from . import enumerate as _enumerate -from . import launch as _launch -from . import resolve_plan as _resolve_plan -from . import smolvm as _smolvm -from .bottle import SmolmachinesBottle -from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan -from .bottle_plan import SmolmachinesBottlePlan - - -class SmolmachinesBottleBackend( - BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"] -): - """smolmachines backend. Selected by - `BOT_BOTTLE_BACKEND=smolmachines`.""" - - name = "smolmachines" - - @classmethod - def is_available(cls) -> bool: - """`smolvm` on PATH. The backend additionally needs macOS - for libkrun + TSI, but `enumerate_active` / `cleanup` are - host-shell ops that gracefully no-op on Linux too — the - runtime check happens at `prepare`.""" - return _smolvm.is_available() - - def _preflight(self) -> None: - _resolve_plan.preflight() - - def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: - return _resolve_plan.build_guest_env(resolved_env) - - def _resolve_plan( - self, - spec: BottleSpec, - *, - manifest: Manifest, - slug: str, - resolved_env: ResolvedEnv, - agent_provision_plan: AgentProvisionPlan, - egress_plan: EgressPlan, - git_gate_plan: GitGatePlan, - supervise_plan: SupervisePlan | None, - stage_dir: Path, - ) -> SmolmachinesBottlePlan: - return _resolve_plan.resolve_plan( - spec, - manifest=manifest, - slug=slug, - resolved_env=resolved_env, - agent_provision_plan=agent_provision_plan, - egress_plan=egress_plan, - supervise_plan=supervise_plan, - git_gate_plan=git_gate_plan, - stage_dir=stage_dir, - ) - - @contextmanager - def launch( - self, plan: SmolmachinesBottlePlan - ) -> Generator[SmolmachinesBottle, None, None]: - with _launch.launch(plan, provision=self.provision) as bottle: - yield bottle - - def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str: - """The smolmachines guest reaches the supervise sidecar via a - host-published random port the launch step pinned earlier - (`http://:/`). `agent_supervise_url` - on the plan is "" when the bottle has no sidecar.""" - return plan.agent_supervise_url - - def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan: - return _cleanup.prepare_cleanup() - - def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None: - _cleanup.cleanup(plan) - - def enumerate_active(self) -> Sequence[ActiveAgent]: - return _enumerate.enumerate_active() diff --git a/bot_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py deleted file mode 100644 index c496919..0000000 --- a/bot_bottle/backend/smolmachines/bottle.py +++ /dev/null @@ -1,217 +0,0 @@ -"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d). - -Routes `exec_agent` / `exec` / `cp_in` through `smolvm machine -exec` / `smolvm machine cp`. The handle is yielded by `launch` -and torn down via the surrounding ExitStack on context exit; -`close` is a no-op idempotent alias so the BottleBackend ABC's -context-manager contract is satisfied. - -User context: `smolvm machine exec` runs commands as root in the -VM, but the agent image's USER is `node` and agent CLIs may refuse -to run as root in bypass modes. Both -`exec_agent` and `exec` switch to the requested user (default -`node`) via `runuser -u --` and set `HOME` / `USER` -through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring -(PAM session setup, /etc/profile sourcing) which can hang on a -minimal Debian VM with no PAM session config.""" - -from __future__ import annotations - -import subprocess -import sys -import time -import shlex -from typing import Mapping, cast - -from ...agent_provider import PromptMode, prompt_args -from .. import Bottle, ExecResult -from ..terminal import exec_shell_script -from . import pty_resize as _pty_resize -from . import smolvm as _smolvm - - -# Absolute path to the pty_resize wrapper. Invoke as -# `python ` rather than `python -m ` so the -# wrapper runs regardless of cwd / sys.path — it has no -# bot_bottle.* imports, so it's self-contained. -_PTY_RESIZE_SCRIPT = _pty_resize.__file__ - - -# Per-user env the agent image's USER (node) expects. Some providers -# write session state under the user's home directory; -# bare `runuser -u` inherits root's HOME=/root, which claude -# can't write to. Set HOME / USER explicitly through smolvm -e -# so the child process sees them. -_HOME_FOR = { - "node": "/home/node", - "root": "/root", -} - -_DEFAULT_PATH_FOR = { - # Committed smolmachine snapshots are rebuilt from a rootfs tarball and - # lose Docker image ENV metadata. Restore the provider CLI path here so - # resumed Codex bottles can still find the per-user install. - "node": ( - "/home/node/.local/bin:" - "/home/node/.codex/packages/standalone/current/bin:" - "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - ), - "root": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", -} - - -def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]: - home = _HOME_FOR.get(user, f"/home/{user}") - out = [f"HOME={home}", f"USER={user}"] - if "PATH" not in env: - out.append(f"PATH={_DEFAULT_PATH_FOR.get(user, _DEFAULT_PATH_FOR['root'])}") - for k, v in env.items(): - out.append(f"{k}={v}") - return out - - -class SmolmachinesBottle(Bottle): - """Handle returned by `SmolmachinesBottleBackend.launch`. The - underlying VM lifecycle (create / start / stop / delete) lives - on the launch ExitStack — this class only routes runtime - operations to the right `smolvm machine ...` subcommand.""" - - def __init__( - self, - machine_name: str, - *, - prompt_path: str | None = None, - guest_env: Mapping[str, str] | None = None, - agent_command: str = "claude", - agent_prompt_mode: PromptMode = "append_file", - agent_provider_template: str = "claude", - terminal_title: str = "", - terminal_color: str = "", - agent_workdir: str = "/home/node", - ) -> None: - self.name = machine_name - # In-VM path to the agent's prompt file. None when the - # agent declared no prompt (file still exists; we just - # don't pass --append-system-prompt-file). - self.prompt_path = prompt_path - # Env vars the agent process needs (HTTPS_PROXY, - # CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …). - # Forwarded on every `smolvm machine exec` via `-e K=V` - # because exec doesn't inherit from machine_create's env. - self._guest_env = dict(guest_env or {}) - self._agent_prompt_mode = agent_prompt_mode - self.agent_command = agent_command - self.terminal_title = terminal_title - self.terminal_color = terminal_color - self.agent_provider_template = agent_provider_template - self.agent_workdir = agent_workdir - - def agent_argv( - self, argv: list[str], *, tty: bool = True, - ) -> list[str]: - flags = ["smolvm", "machine", "exec", "--name", self.name] - if tty: - flags += ["-i", "-t"] - agent_tail = ["env", *_env_assignments_for("node", self._guest_env)] - if self.agent_workdir and self.agent_workdir != _HOME_FOR["node"]: - agent_tail += [ - "sh", "-lc", - f"cd {shlex.quote(self.agent_workdir)} && exec \"$@\"", - "bot-bottle-agent", - ] - agent_tail.append(self.agent_command) - provider_prompt_args = prompt_args( - cast(PromptMode, self._agent_prompt_mode), self.prompt_path, argv=argv, - ) - if cast(PromptMode, self._agent_prompt_mode) == "read_prompt_file": - agent_tail += argv - agent_tail += provider_prompt_args - else: - agent_tail += provider_prompt_args - agent_tail += argv - flags += ["--", "runuser", "-u", "node", "--", *agent_tail] - if not tty: - # No PTY allocated — no SIGWINCH to forward, no resize - # bridge needed. Skip the wrapper so non-interactive - # exec paths (e.g., provisioning shell-outs that - # happen to go through this method) stay light. - return flags - return [ - sys.executable, _PTY_RESIZE_SCRIPT, - self.name, "--", *flags, - ] - - def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: - """Run the selected agent interactively inside the VM as the `node` - user. Inherits the operator's terminal (stdin / stdout / - stderr) so the session feels native. Blocks until the agent - exits; returns the in-VM exit code. - - We bypass the captured-output `machine_exec` helper here - because that one wraps stdout/stderr in pipes — fine for - scripted exec, wrong for an interactive shell. Drop down - to `subprocess.run` with the TTY inherited. - - UID switches via `runuser -u node --` (not `-l`) so we - avoid login-shell wiring. HOME / USER come from `smolvm - -e` instead, which sets them on the process env.""" - agent_argv = self.agent_argv(argv, tty=tty) - script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None - if script is None: - return subprocess.run(agent_argv, check=False).returncode - # Use sh -c (not -lc) so the script inherits PATH from the calling - # process. sh -l sources login-shell init files (e.g. /etc/profile) - # which may NOT include smolvm's location when it was installed via - # homebrew. The calling process (./cli.py) already has smolvm on PATH - # (provision steps succeed), so -c is sufficient. - return subprocess.run(["sh", "-c", script], check=False).returncode - - # smolvm/libkrun can SIGKILL an otherwise-normal exec during - # early-VM provisioning. Retry once after a short settle so - # callers (provision_ca, etc.) don't have to handle it themselves. - _SIGKILL_EXIT = 128 + 9 - - def exec(self, script: str, *, user: str = "node") -> ExecResult: - """Run a POSIX shell script as `user` (default `node`) and - capture the result. Matches the docker backend's `exec`, - which defaults to the image's USER (also node) — so test - helpers / provision shell-outs run with the same identity - on both backends. Pass `user="root"` for tests that need - root. - - `runuser -u -- env ... /bin/sh -c