Files
bot-bottle/docs/prds/0023-smolmachines-backend.md
T
didericis 4e00430c6e
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m11s
docs(prd-0023): consume PRD 0024's bundle as the single sidecar
Replace the four host-side sidecar processes (pipelock + egress +
git-gate + supervise) with a single bundled container per bottle,
defined in PRD 0024 and consumed here. egress is internal to the
bundle as pipelock's upstream; only pipelock, git-gate, and
supervise are externally addressable, and only when the bottle
uses them.

gvproxy port_forwards collapse from one-per-process to one-per-
external-port, all pointing into the one bundle container.
Sizing: chunk 3 becomes "sidecar bundle lifecycle" and depends
on PRD 0024 having landed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:51:57 -04:00

31 KiB

PRD 0023: smolmachines bottle backend

  • Status: Draft
  • Author: didericis
  • Created: 2026-05-26

Summary

Ship a second concrete BottleBackendSmolmachinesBottleBackend, selected via CLAUDE_BOTTLE_BACKEND=smolmachines — that runs each bottle inside a per-agent microVM on macOS. The egress topology is enforced by gvproxy (gvisor-tap-vsock), a userspace TCP/IP stack the guest's virtio-net device is wired into via VZFileHandleNetworkDeviceAttachment. gvproxy's only outbound configuration is an explicit per-bottle port-forward set into a single per-bottle sidecar container that bundles pipelock + egress + git-gate + supervise behind one supervised init. Everything else — the host's LAN, the host's loopback services, the public internet — is unreachable from the guest by construction.

The sidecar bundle is the same image PRD 0024 introduces for the docker backend; this PRD consumes it. Inside the bundle, egress is pipelock's internal upstream over localhost and is not exposed externally. gvproxy port-forwards three external ports into the bundle: pipelock (for HTTPS_PROXY), git-gate (for git push), and supervise (for MCP).

This explicitly rejects libkrun's TSI ("Transport Socket Interface") allowlist as the network primitive. TSI's --outbound-localhost-only is permissive on the entire 127.0.0.0/8 range with no destination-port filter — the agent can dial any host-side service bound to loopback (a local Postgres, an IDE plugin, a different bottle's pipelock). That's the wrong default for a malicious-agent threat model; see "Why gvproxy, not TSI" below.

The Docker backend ships unchanged; this is opt-in via the existing env-var selector. The acceptance gate is PRD 0022's tests/integration/test_sandbox_escape.py running green against CLAUDE_BOTTLE_BACKEND=smolmachines.

Problem

agent-vm-isolation.md argues for hardware-isolated microVMs over container-based bottles on macOS; smolmachines-as-vm-backend.md evaluates smolmachines as the lifecycle wrapper. Today, the only backend in the registry is Docker (claude_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}), and four things motivate a second one now:

  • Network reach beyond pipelock. The threat model is a malicious agent attempting to dial something on the operator's local network (192.168.x.x services, the home router, a coworker's laptop on the same Wi-Fi) or host's loopback (a local database, an editor plugin, another bottle's sidecar) without traversing pipelock. The Docker backend's --internal network blocks the first; nothing in the current stack blocks the second cleanly. This PRD's gvproxy-based design closes both gaps: the guest can only reach the explicit port-forward list, period.
  • Isolation ceiling. On macOS the Docker backend's agent container shares Docker Desktop's host VM with every other bottle. Container escape from claude-code lands the agent inside that shared VM. A per-bottle microVM gets hardware page tables via Hypervisor.framework; cross-bottle isolation becomes enforced by the CPU's MMU instead of namespace bookkeeping.
  • PRD 0022 is backend-agnostic by design but currently only exercises the Docker backend. The suite was written with CLAUDE_BOTTLE_BACKEND selection in mind precisely so the smolmachines path could be validated against the same five attacks. Until a second backend exists, the abstraction is unproven.
  • CI carve-outs. Most bottle-bringup integration tests skip under GITEA_ACTIONS=true because act_runner shares the host Docker socket but not the host filesystem. A microVM path doesn't share that constraint shape (it has its own, but different), so adding the backend forces the abstraction to be clean in places where Docker-specific assumptions have been tolerated.

Why gvproxy, not TSI

libkrun's TSI hijacks guest socket syscalls inside the VMM and opens the actual sockets from the host process, with a CIDR allowlist gate. That works fine for blocking LAN reach (don't allowlist 192.168.0.0/16, agent can't dial it). But TSI's --outbound-localhost-only permits the entire 127.0.0.0/8 range across all ports — there is no destination-port filter at the TSI layer (smolmachines-as-vm-backend.md flags this in the "--allow-host semantics" caveat). For our threat model that means any host-loopback service is reachable from the guest.

gvproxy implements a full userspace TCP/IP stack on the host side of a VZFileHandleNetworkDeviceAttachment unixgram socket. The guest has a real virtio-net device; gvproxy is its gateway. The guest can only reach what gvproxy is configured to forward — typically a single port forward to the per-bottle pipelock — and DNS resolves NXDOMAIN by default. There is no "permissive loopback" mode to mis-configure; if it's not in port_forwards, the guest cannot reach it.

That property — explicit allowlist by port forward, not CIDR — is the load-bearing reason this PRD chooses gvproxy. TSI shows up once more in this doc, under Non-goals, where it is closed off.

Goals / Success Criteria

The feature works when all of the following are observable on a macOS host with smolmachines installed:

  • CLAUDE_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent> brings up a microVM, runs claude-code inside it, and tears it down on exit. Same y/N preflight UX as Docker — only the resolved-runtime line differs.
  • The sandbox-escape suite in tests/integration/test_sandbox_escape.py runs green against the smolmachines backend (all five attack categories blocked).
  • Selecting the backend on a host without smolvm installed dies at startup with an install pointer; no silent fall-through to Docker.
  • Active bottles show up under python3 cli.py list-bottles regardless of backend.
  • python3 cli.py stop <bottle> and orphan cleanup work for both Docker bottles and smolmachines bottles via the same CLI surface.

The feature is done when all of the following ship:

  • A new claude_bottle/backend/smolmachines/ subpackage exists, mirroring the layout of claude_bottle/backend/docker/ (backend.py, bottle.py, bottle_plan.py, bottle_cleanup_plan.py, prepare.py, launch.py, cleanup.py, util.py, and a provision/ subpackage for the five provision_* methods).
  • SmolmachinesBottleBackend registered under the "smolmachines" key in claude_bottle/backend/__init__.py:_BACKENDS.
  • Per-bottle Smolfile generation: a runtime-rendered TOML written to the bottle's stage dir, analogous to the compose file the Docker backend writes today. The Smolfile pins command, env, and a virtio-net device backed by a unixgram socket pointed at the per-bottle gvproxy. There is no TSI --allow-cidr / --outbound-localhost-only / --allow-host in the Smolfile — TSI is not used.
  • Per-bottle gvproxy: one gvproxy process per bottle, started before the VM, listening on a unixgram socket the VM's virtio-net device hooks into. The gvproxy config has up to three port_forwards entries (pipelock / git-gate / supervise — git-gate and supervise only when the bottle uses them) all pointing at the per-bottle sidecar bundle's exposed ports, plus a DNS section that resolves only proxy.internal. Every other hostname returns NXDOMAIN; every other destination is unreachable.
  • Per-bottle sidecar bundle: one container per bottle running the bundle image defined in PRD 0024. The bundle exposes up to three host ports (pipelock for HTTPS_PROXY, git-gate for git push, supervise for MCP), bound to 127.0.0.1 on dynamically allocated ports. egress runs inside the bundle as pipelock's upstream over localhost and is not exposed externally. The agent's environment carries the resolved URLs (e.g. HTTPS_PROXY=http://proxy.internal:<pipelock-gateway-port>).
  • The agent guest image is produced from the existing Dockerfile (or a thin variant), exported as an OCI archive, and consumed by smolvm machine create. The image build step is part of prepare, analogous to docker_mod.build_image.
  • The PRD 0022 sandbox-escape suite, run with CLAUDE_BOTTLE_BACKEND=smolmachines, passes locally on a smolmachines-capable host. The suite is updated to skip cleanly on hosts that can't reach smolmachines (same shape as the existing GITEA_ACTIONS == "true" skip), not to fail.
  • README + CLAUDE.md updated to document the env-var selection, the macOS-only scope for v1, and the smolvm install prerequisite.

Non-goals

  • No Linux KVM support shipped in this PRD. smolmachines works on Linux via KVM, but the abstraction win is biggest on macOS where Docker's shared-VM topology hurts most. Linux can come later behind the same selector.
  • No removal of the Docker backend. Both backends ship side by side. Selection stays env-driven; the manifest does not gain a backend field.
  • No default-backend change. docker remains the default value of CLAUDE_BOTTLE_BACKEND; smolmachines is strictly opt-in until it has been load-bearing on at least one operator's workflow for a release cycle.
  • No TSI for network policy. libkrun's TSI mode is rejected for this backend — it lacks per-port filtering on 127.0.0.0/8 and would expose every host-loopback service to the guest. The Smolfile must select libkrun's virtio-net mode and attach to the per-bottle gvproxy unixgram socket; if that combination is not supported by the pinned smolmachines version (see open question 1), the implementation falls back to driving Virtualization.framework directly via PyObjC and reuses the same gvproxy attachment.
  • No host bind mounts. The smolmachines research note flagged that -v HOST:GUEST mounts via virtiofs would defeat the isolation goal. The manifest already has no concept of host mounts; this PRD does not introduce one. If a future PRD wants agent-side access to host files, it must come through a controlled channel (vsock relay, OCI overlay, supervise sidecar endpoint).
  • No HTTP API mode. smolvm serve is the long-term-clean control plane, but v1 drives smolmachines via CLI subprocess invocations — the lower-overhead first iteration the research note already endorses.
  • No custom kernel / initrd. smolmachines uses libkrunfw only; the agent image is an OCI ref, not a kernel + rootfs pair.
  • No warm-pool or snapshot/restore. Each bottle gets a fresh microVM; cold-start cost is paid up front.
  • No supervise/agent-credential rewrites for the new backend. Provisioning logic ports as-is; only the transport (host-side port URLs instead of in-network DNS names) changes.

Scope

In scope

  • New claude_bottle/backend/smolmachines/ subpackage with the full set of BottleBackend overrides.
  • Smolfile generator (TOML), analogous to backend/docker/compose.py's bottle_plan_to_compose.
  • A host-side sidecar-bundle lifecycle manager that brings up one container per bottle (the bundle image defined in PRD 0024), publishes its one to three host ports, waits for readiness, and tears it down with the bottle. This backend depends on PRD 0024's bundle image; it does not own the bundle's Dockerfile or init.
  • Per-bottle CA install path: the bundle's CA cert lands inside the microVM via smolvm machine exec after start (analogous to the existing provision_ca for Docker).
  • gvproxy lifecycle: per-bottle gvproxy started by the backend before VM bringup, torn down after VM teardown, configured with up to three port_forwards entries (gateway port → host bundle port for each of pipelock / git-gate / supervise) and a DNS section that resolves only proxy.internal. Subnet and gateway IP are derived from the bottle slug so two concurrent bottles don't collide.
  • DNS policy: the bottle's egress.allowlist does not go into gvproxy's DNS — the agent resolves only proxy.internal, and pipelock on the host enforces the egress allowlist against the actual upstream connect target. This keeps the DNS-exfil attack (PRD 0022 test 4) blocked because gvproxy answers NXDOMAIN for every name except proxy.internal.
  • Preflight smolvm check: if the user selects this backend and smolvm isn't on $PATH, die with an install pointer (brew tap
    • version pin TBD in implementation; see open question 3).
  • Manifest validation: refuse any bottle field this backend can't honor (today there are none, since the Docker backend already rejects host mounts; this is a forward-compat check).
  • Tests:
    • Smoke unit-level test: Smolfile renderer produces the expected TOML for a fixture bottle.
    • Integration test: prepare → launch → exec("echo hi") → teardown on a smolmachines-capable host (skips otherwise via the same env/platform gate the Docker integration tests use).
    • PRD 0022 suite, re-run with the env var flipped, passes.

Out of scope

  • VM image caching across bottles (each prepare rebuilds from the OCI archive; layer reuse is whatever smolmachines provides).
  • Cross-host bottle relocation (the OCI archive is local-only).
  • Operator-facing knobs for vCPU / memory / overlay size (use sensible defaults; expose as manifest fields in a later PRD if needed).
  • Integration with the supervise plane's permission-prompt UX beyond port plumbing — supervise already speaks HTTP and binds to whatever loopback the backend hands it.

Proposed Design

Backend layout

claude_bottle/backend/smolmachines/
  __init__.py            re-exports SmolmachinesBottleBackend
  backend.py             SmolmachinesBottleBackend façade
  bottle.py              SmolmachinesBottle (exec_claude / exec / cp_in / close)
  bottle_plan.py         SmolmachinesBottlePlan + .print()
  bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan
  prepare.py             resolve_plan(spec, stage_dir, ...) -> SmolmachinesBottlePlan
  launch.py              @contextmanager launch(plan) -> SmolmachinesBottle
  cleanup.py             prepare_cleanup / cleanup / list_active
  smolfile.py            bottle_plan_to_smolfile(...) -> dict + render
  gvproxy.py             per-bottle gvproxy config render + process lifecycle
  sidecar_bundle.py      host-side lifecycle for the PRD 0024 bundle container
  smolvm.py              thin subprocess wrapper: machine create/start/exec/stop
  vfkit_attach.py        VZFileHandleNetworkDeviceAttachment + VFKT handshake
  util.py                slugify, port allocation, OCI archive helpers
  provision/             ca.py, prompt.py, skills.py, git.py, supervise.py

Network + egress topology

  ┌── macOS host ─────────────────────────────────────────────────────┐
  │                                                                   │
  │  ┌── per-bottle sidecar bundle (one container per microVM) ─┐     │
  │  │  init.py (Python supervisor)                              │    │
  │  │   ├─ pipelock          (binds 0.0.0.0:8888 in container)  │    │
  │  │   ├─ egress (mitmproxy) (binds 127.0.0.1:p_internal)      │    │
  │  │   ├─ git-gate          (binds 0.0.0.0:8889)               │    │
  │  │   └─ supervise (MCP)    (binds 0.0.0.0:8890)              │    │
  │  │  pipelock's upstream is 127.0.0.1:p_internal (egress);    │    │
  │  │  egress is not exposed outside the bundle.                │    │
  │  └─────────────────────────────────────────────────────┬─────┘    │
  │     Host ports published (loopback, dynamic):          │          │
  │       pipelock  127.0.0.1:<p1>                         │          │
  │       git-gate  127.0.0.1:<p2>  (conditional)          │          │
  │       supervise 127.0.0.1:<p3>  (conditional)          │          │
  │             ▲ host TCP, reached via gvproxy port-forward          │
  │             │                                                     │
  │  ┌── gvproxy (per bottle) ─────────────────────────────┐          │
  │  │   subnet: 192.168.127.X/24  (X derived from slug)   │          │
  │  │   gateway: 192.168.127.X.1                          │          │
  │  │   port_forwards:                                    │          │
  │  │     - gateway 8888 → host 127.0.0.1:<p1>            │          │
  │  │     - gateway 8889 → host 127.0.0.1:<p2>  (cond)    │          │
  │  │     - gateway 8890 → host 127.0.0.1:<p3>  (cond)    │          │
  │  │     # nothing else                                  │          │
  │  │   DNS: proxy.internal → gateway IP; * → NXDOMAIN    │          │
  │  └─────────────────────────────────────────────────────┘          │
  │             ▲ unixgram socket (VFKT handshake)                    │
  │             │                                                     │
  │  ┌── microVM (per bottle) ─────────────────────────────┐          │
  │  │   virtio-net device backed by VZFileHandle...       │          │
  │  │   env: HTTPS_PROXY=http://proxy.internal:8888       │          │
  │  │        GIT_GATE_URL=http://proxy.internal:8889      │          │
  │  │        MCP_SUPERVISE_URL=http://proxy.internal:8890 │          │
  │  │   no other host visible                             │          │
  │  └─────────────────────────────────────────────────────┘          │
  │                                                                   │
  └───────────────────────────────────────────────────────────────────┘

What the guest can reach, exhaustively: only proxy.internal on the gateway-port set we configured. Everything else — host LAN, host loopback (Postgres, IDE plugins, other bottles' sidecars), public internet directly — is gone, enforced at the gvproxy userspace stack rather than relying on guest cooperation.

Three changes vs. the Docker backend:

  1. One sidecar container per bottle, not four. The bundle defined in PRD 0024 is the unit of sidecar lifecycle on both backends. egress is internal to the bundle as pipelock's upstream, never directly addressed.
  2. Sidecar container is on the host, not a sibling on a Docker internal network. Isolation primitive is gvproxy's explicit port-forward list, not Docker's --internal flag.
  3. The agent's first hop is proxy.internal, not a sidecar's container hostname. Same scanning + DLP + auth-injection chain, but the first hop crosses a userspace TCP/IP stack we own, not a Docker-managed bridge.

git-gate and supervise are conditional port forwards: only emitted into gvproxy's config when the bottle actually uses them, narrowing the attack surface for bottles that don't.

Lifecycle

SmolmachinesBottleBackend.prepare(spec, stage_dir):

  1. Cross-backend validation via BottleBackend._validate (skills, git identity files).
  2. Allocate one to three host loopback ports for the sidecar bundle (pipelock always; git-gate and supervise conditional on manifest — egress is internal to the bundle and gets no host port).
  3. Resolve the agent OCI archive path (build if missing, cache by Dockerfile + agent-name hash). The sidecar-bundle image (claude-bottle-sidecars:<pinned>) is pulled or built per PRD 0024; this backend does not own its build.
  4. Pick a per-bottle gvproxy subnet (e.g. 192.168.127.X/24 where X is derived from the slug) and render stage_dir/gvproxy.yaml: one DNS entry for proxy.internal and one port_forwards entry per active sidecar port (gateway port → host loopback port on the bundle).
  5. Render the per-bottle Smolfile to stage_dir/smolfile.toml, pinning command / env / a virtio-net device backed by the gvproxy unixgram socket path. No TSI flags.
  6. Resolve the in-VM CA paths so launch knows where to copy pipelock's CA after start.
  7. Return a SmolmachinesBottlePlan carrying the slug, port map, OCI archive path, Smolfile path, gvproxy config path, and the bundle's container/run spec.

SmolmachinesBottleBackend.launch(plan):

  1. Start the sidecar bundle container with docker run (still using the local Docker daemon for sidecars; the VM is what's moving off Docker). Wait for its three readiness signals: pipelock listening, git-gate listening (if enabled), supervise listening (if enabled). Register the teardown callback.
  2. Start the per-bottle gvproxy against the unixgram socket path the Smolfile references, with port_forwards pointed at the bundle's published host ports. Wait for the socket to appear (the spike-style poll loop from agent-vm-isolation.md).
  3. smolvm machine create --smolfile <path> and smolvm machine start <name>. The Smolfile's virtio-net device handshakes (VFKT magic) with gvproxy on start.
  4. Provisioning: CA install → prompt → skills → git → supervise config, each via smolvm machine exec (analogous to docker exec).
  5. Yield a SmolmachinesBottle whose exec_claude / exec / cp_in all funnel through smolvm machine exec / smolvm machine cp.
  6. Teardown: stop and remove the VM → stop gvproxy → stop + remove the sidecar bundle container.

Data model

No manifest schema change. bottles[] continues to carry egress.allowlist, env, git, skills references, etc.; the smolmachines backend reads the same fields as the docker backend. egress.allowlist is enforced by pipelock on the host side (unchanged from the docker backend); gvproxy's DNS resolves only proxy.internal regardless of the allowlist's contents, so an agent that bypasses pipelock by raw IP cannot resolve any name gvproxy doesn't know about.

The BottleSpec dataclass and the Bottle ABC do not change.

Selection wiring

In claude_bottle/backend/__init__.py:

from .docker import DockerBottleBackend
from .smolmachines import SmolmachinesBottleBackend

_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
    "docker": DockerBottleBackend(),
    "smolmachines": SmolmachinesBottleBackend(),
}

The existing "unknown backend" die() path stays as-is.

External dependencies

  • smolvm CLI binary on $PATH (one new external dep, gated by the preflight check). Pinned version policy is deferred to the open questions; v1 reads smolvm --version and refuses to launch outside a known-good range.
  • gvproxy binary on $PATH (go install github.com/containers/gvisor-tap-vsock/cmd/gvproxy@latest, or vendored). Same preflight pattern as smolvm.
  • pyobjc-framework-Virtualization only if smolmachines does not expose a way to attach virtio-net to a unixgram socket and we fall back to driving Virtualization.framework directly (see open question 1). Default path is "no PyObjC needed."
  • No new pure-Python packages. Subprocess + stdlib tomllib for Smolfile authoring; the gvproxy YAML is small enough to render by hand from a dict[str, Any].

Acceptance test plan

  • Unit (smolfile): tests/unit/test_smolfile.py verifies the renderer produces the expected TOML for a fixture bottle — command line, env entries, virtio-net device referencing the expected unixgram socket path, no TSI flags.
  • Unit (gvproxy config): tests/unit/test_gvproxy_config.py verifies the per-bottle YAML has exactly one DNS entry (proxy.internal), one port_forwards entry per active sidecar pointed at the resolved host loopback port, and a per-bottle subnet/gateway derived from the slug.
  • Integration smoke: tests/integration/test_smolmachines_smoke.py with prepare → launch → exec → teardown, guarded by a smolvm + gvproxy presence check + macOS / KVM platform check.
  • Localhost-reach probe: a focused integration test that brings up a bottle, has the host bind a test service on 127.0.0.1:<unused-port>, and asserts the in-bottle agent cannot connect to it. This is the regression test for the exact gap that motivated choosing gvproxy over TSI.
  • PRD 0022 re-run: with CLAUDE_BOTTLE_BACKEND=smolmachines, all five attack categories return sandbox-block markers and the suite passes. The test code does not change beyond the env-var flip — that's the contract the PRD 0022 abstraction was designed for.

Sizing — into chunks

PRD 0024's bundle image is a prerequisite — this PRD assumes claude-bottle-sidecars:<pinned> is available when chunk 3 lands.

  1. Backend skeleton + selection + Smolfile + gvproxy renderers. Subpackage layout, _resolve_plan stub that emits both a TOML Smolfile and a gvproxy YAML but doesn't launch anything, _BACKENDS registration, preflight smolvm + gvproxy checks. Unit tests on both renderers. No VM bringup yet.
  2. gvproxy + VM lifecycle + OCI archive build. smolvm.py and gvproxy.py subprocess wrappers, prepare-time image build (existing Dockerfile → OCI archive), launch path that starts gvproxy, brings up the VM attached to gvproxy's socket via VFKT handshake, exec into the VM, tear everything down. Smoke integration test: exec("echo hi") inside a started VM. Includes the localhost-reach probe test from the acceptance plan.
  3. Sidecar bundle lifecycle. sidecar_bundle.py: per-bottle bundle container brought up via docker run, with one to three published host ports, gvproxy port_forwards pointed at them, and teardown integrated into the bottle's lifecycle. Port allocator. No provisioning yet beyond what the bundle needs.
  4. Provisioning parity with Docker. CA install via smolvm machine exec, prompt/skills/.git copy-in, supervise MCP config. End-to-end start works for a real agent manifest.
  5. PRD 0022 sandbox-escape suite green. Skip-guard update, small adjustments to test helpers if any (the test uses bottle.exec(script) and inspects returncode + body for sandbox markers — should be transport-agnostic, but verify). Document the macOS-only scope in README.

Open questions

  1. VMM choice: smolmachines vs PyObjC + Virtualization.framework. The network design requires libkrun's virtio-net mode attached to a unixgram socket (so gvproxy is the gateway). The smolmachines research note says libkrun has a virtio-net mode but says it "does not support policy" — meaning libkrun itself enforces no allowlist in that mode, which is exactly what we want (gvproxy is the policy). What's unverified is whether the Smolfile surface lets us point virtio-net at a custom unixgram socket. If yes: this is a smolmachines backend verbatim. If no: chunk 2 drops smolvm and drives Virtualization.framework via PyObjC directly (the recipe in agent-vm-isolation.md § "gvisor-tap-vsock + PyObjC + Pipelock"), keeping the backend name "smolmachines" because the operator-facing UX is unchanged. Resolve in chunk 1 via a spike against the pinned smolmachines version.
  2. smolvm + gvproxy install policy. Pin via brew / go install versions, or vendor binaries in the repo. v1 likely runs smolvm --version / gvproxy --help at preflight and accepts a documented range; vendoring is heavier but reduces "works on my Mac" drift.
  3. CA install inside the OCI overlay. Two paths: bake at prepare time (one OCI archive per CA fingerprint, big cache key) vs. inject at start time via smolvm machine exec after the VM is up. PRD 0006 chose the runtime path for Docker (docker-cp + update-ca-certificates); smolvm has the same shape via machine exec. Default to runtime injection unless it conflicts with VM start order.
  4. gvproxy subnet collision. Two concurrent bottles must not land on the same 192.168.127.X/24 subnet — they'd both want the same gateway IP. Derive the third octet from a hash of the slug (mod 254, skip the docker-default 17), and at launch time confirm the subnet isn't already in use by another bottle's gvproxy. Resolve the hash-collision policy in chunk 2.
  5. bottle.exec(script) exit-code fidelity. The PRD 0022 test suite reads returncode + stdout + stderr from ExecResult. Confirm the VM-exec path (smolvm machine exec or its PyObjC equivalent) propagates exit codes and separated streams. The research note's "external integration is the CLI" implies yes, but the embedded SDK bug it flagged suggests we should verify before coding around it.
  6. CI gating. Gitea's act_runner is Linux without nested KVM, so this backend's integration tests will skip there for the same structural reason the Docker bringup tests do (no real isolation primitive available on the runner). The skip predicate becomes not (smolvm_available() and gvproxy_available() and platform.system() == "Darwin"). CI coverage for this backend will come from local runs on the maintainer's macOS host until a Darwin runner is wired up; ack that as a known gap.
  7. Active bottle discovery. Docker uses container labels to enumerate active bottles (list_active queries the daemon). The microVM enumeration story is smolvm machine list (or the PyObjC backend's own bookkeeping); the plan is to mirror the label scheme via Smolfile metadata (labels = { "claude-bottle" = "1" }-style entries, if the format supports it; otherwise via a deterministic name prefix claude-bottle-<slug> + on-disk metadata under state/<slug>/).

References

  • docs/research/agent-vm-isolation.md — primary reference for the gvproxy + VZFileHandleNetworkDeviceAttachment network attachment used here. The "Full Setup: gvisor-tap-vsock + PyObjC + Pipelock" section is the recipe the PyObjC fallback in open question 1 would adopt verbatim.
  • docs/research/smolmachines-as-vm-backend.md — evaluation of smolmachines as the VM lifecycle wrapper. This PRD diverges from its conclusion on the network primitive (rejecting TSI in favor of gvproxy) but keeps its VM-lifecycle conclusion conditional on the libkrun-virtio-net spike in open question 1.
  • docs/research/agent-sandbox-landscape.md — identifies "runtime": "microvm"-style opt-in as the borrowable idea; smolmachines is the concrete implementation.
  • PRD 0003 (docs/prds/0003-bottle-backend-abstraction.md) — the backend abstraction this PRD is the first non-Docker consumer of.
  • PRD 0017 (docs/prds/0017-egress-proxy-via-mitmproxy.md) — the egress sidecar the bundle reuses verbatim as pipelock's internal upstream.
  • PRD 0022 (docs/prds/0022-sandbox-escape-integration-test.md) — the acceptance gate for this PRD; the suite already runs through get_bottle_backend() so the env-var flip is the only change needed to exercise the smolmachines path.
  • PRD 0024 (docs/prds/0024-consolidate-sidecar-bundle.md) — defines the single bundle image (claude-bottle-sidecars) this PRD consumes. Prerequisite for chunk 3 of this PRD.