9f65b137b9
End-to-end launch flow for the smolmachines backend. Brings up
the per-bottle docker bridge + sidecar bundle, creates and
starts the smolvm guest pointed at the bundle's pinned IP via
TSI's `--allow-cidr <bundle-ip>/32`, yields a SmolmachinesBottle
handle that routes exec/cp through `smolvm machine exec / cp`,
tears everything down on context exit.
launch.py:
- ExitStack-managed: create_bundle_network → start_bundle →
machine_create → machine_start (each registered for reverse
teardown).
- daemons_csv="" for chunk 2d — bundle init logs "no daemons
selected" and idles. Real daemon bringup with inner-Plan-driven
env + volumes lands in chunk 4.
bottle.py:
- SmolmachinesBottle.exec → smolvm.machine_exec (captured).
- SmolmachinesBottle.exec_claude → direct subprocess.run with
inherited TTY for interactive sessions.
- SmolmachinesBottle.cp_in → smolvm.machine_cp.
Architecture pivots forced by smolvm 0.8.0's CLI shape:
1. `--from <smolmachine>` and `--smolfile <toml>` are MUTUALLY
EXCLUSIVE in smolvm 0.8.0. We need --from to avoid the
registry-pull race that bit us on machine_start (libkrun
agent's network attempt got refused by macOS with
"connect: permission denied" on IPv6). So Smolfile is dropped
entirely; per-bottle env + allow_cidrs flow as CLI flags
(`--allow-cidr CIDR`, `-e K=V`) directly to machine_create.
2. `smolvm pack create --image` doesn't pull from the local
docker daemon — only OCI registries via crane. The real
claude-bottle:latest image lives in the local docker daemon
and isn't reachable that way. Chunk 2d ships with an alpine
placeholder; the agent-image-conversion gap belongs to
chunk 4 (push the image to a registry, or smolvm grows a
docker-daemon transport).
Other changes:
- machine_create grew `image=` / `from_path=` / `allow_cidrs=`
/ `env=` kwargs; smolfile= dropped.
- bottle_plan: smolfile_path → agent_from_path + guest_env.
- prepare: pack_create against `alpine:latest`, cached under
~/.cache/claude-bottle/smolmachines/ keyed by image ref.
- Deleted smolfile.py + test_smolfile.py (dead code now).
Tests:
- Unit: 540 passing (smolvm wrapper grew 4 new flag forms; one
test renamed to reflect --from + --allow-cidr + -e combo).
- Integration: 3 new cases in tests/integration/
test_smolmachines_launch.py, gated on Darwin + smolvm on PATH
+ docker + not GITEA_ACTIONS:
* smoke: bottle.exec("echo hello-from-vm") round-trips with
the correct stdout + returncode.
* localhost-reach probe: agent dials 127.0.0.1:9 → connect
refused (TSI's <bundle-ip>/32 allowlist doesn't include
loopback). The regression test for the gap the PRD design
pivot was about.
* egress-port-bypass probe: agent dials <bundle-ip>:9099
(egress's port) → connect refused. Chunk 2d has no
daemons running so nothing's listening anyway; chunk 3
will preserve this property once egress is up but bound
to 127.0.0.1 inside the bundle.
End-to-end smoke + both probes green locally on macOS with
smolvm 0.8.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
81 lines
3.1 KiB
Python
81 lines
3.1 KiB
Python
"""SmolmachinesBottlePlan — concrete BottlePlan for the smolmachines
|
|
backend (PRD 0023).
|
|
|
|
Slug + bundle docker subnet / gateway / pinned IP + smolvm
|
|
machine name + agent `.smolmachine` artifact + per-bottle guest
|
|
env. Provisioning fields (CA cert path, prompt path, etc.) land
|
|
in chunk 4."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from ...log import info
|
|
from .. import BottlePlan
|
|
from ..print_util import print_multi
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SmolmachinesBottlePlan(BottlePlan):
|
|
"""Resolved fields the launch step needs to bring up the bottle.
|
|
|
|
Inherits `spec` and `stage_dir` from BottlePlan."""
|
|
|
|
slug: str
|
|
# Per-bottle docker subnet for the sidecar bundle container.
|
|
# The bundle runs at `bundle_ip` (always `.2`); the gateway is
|
|
# at `.1`. smolvm's TSI allowlist is set to `bundle_ip/32`.
|
|
bundle_subnet: str
|
|
bundle_gateway: str
|
|
bundle_ip: str
|
|
# smolvm machine name + agent image source. machine_create
|
|
# boots from a packed `.smolmachine` artifact (pre-baked at
|
|
# prepare time via `smolvm pack create`); using `--from`
|
|
# instead of `--image` avoids the registry-pull race we hit
|
|
# when machine_start tried to fetch on-demand and the libkrun
|
|
# agent's network attempt got refused by macOS.
|
|
#
|
|
# Chunk 2d ships with a public placeholder image (alpine)
|
|
# since claude-bottle:latest lives in the operator's local
|
|
# docker daemon and smolvm's crane backend can't read from
|
|
# there; chunk 4 resolves the agent-image-conversion gap
|
|
# (push to a registry first, or smolvm grows a docker-daemon
|
|
# transport).
|
|
machine_name: str
|
|
agent_from_path: Path
|
|
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
|
|
# the guest has no DNS resolver inside the TSI allowlist.
|
|
# Passed to `smolvm machine create` as `-e K=V` flags.
|
|
# Smolfile-rendering is gone (smolvm 0.8.0's
|
|
# `--smolfile` is mutually exclusive with `--from`, and
|
|
# `--from` is the path that avoids the registry-pull race).
|
|
guest_env: dict[str, str]
|
|
|
|
def print(self, *, remote_control: bool) -> None:
|
|
"""Compact y/N preflight. Same shape as the Docker
|
|
backend's so operators see one format across backends."""
|
|
del remote_control # not surfaced in the compact summary
|
|
spec = self.spec
|
|
manifest = spec.manifest
|
|
agent = manifest.agents[spec.agent_name]
|
|
bottle = manifest.bottle_for(spec.agent_name)
|
|
|
|
env_names = sorted(bottle.env.keys())
|
|
upstreams = [
|
|
f"{g.Name} → {g.Upstream}" for g in bottle.git
|
|
]
|
|
routes = [r.host for r in bottle.egress.routes]
|
|
|
|
print(file=sys.stderr)
|
|
info(f"agent : {spec.agent_name}")
|
|
print_multi("env ", env_names)
|
|
print_multi("skills ", list(agent.skills))
|
|
info(f"bottle : {agent.bottle}")
|
|
if upstreams:
|
|
print_multi(" git gate ", upstreams)
|
|
if routes:
|
|
print_multi(" egress ", routes)
|
|
print(file=sys.stderr)
|