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>
128 lines
4.7 KiB
Python
128 lines
4.7 KiB
Python
"""smolmachines `_resolve_plan` (PRD 0023 chunk 2d).
|
|
|
|
Resolves the per-bottle docker subnet + bundle IP, pre-packs the
|
|
agent's `.smolmachine` artifact (cached under
|
|
`~/.cache/claude-bottle/smolmachines/`), and assembles the guest
|
|
env. No VM bringup — that's `launch.launch`'s job."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from ...backend import BottleSpec
|
|
from ...backend.docker.bottle_state import (
|
|
BottleMetadata,
|
|
bottle_identity,
|
|
write_metadata,
|
|
)
|
|
from . import smolvm as _smolvm
|
|
from .bottle_plan import SmolmachinesBottlePlan
|
|
from .util import smolmachines_bundle_subnet, smolmachines_preflight
|
|
|
|
|
|
# Per-host cache for `smolvm pack create` outputs. Keyed by the
|
|
# image ref so re-prepares for the same image hit the cache
|
|
# (pack create is idempotent on the smolvm side but takes several
|
|
# seconds even when no layer is fetched).
|
|
_SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "claude-bottle" / "smolmachines"
|
|
|
|
|
|
# Gateway ports the bundle exposes inside its container — pipelock
|
|
# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent
|
|
# inside the smolvm guest dials these on the bundle's pinned IP.
|
|
_BUNDLE_PIPELOCK_PORT = 8888
|
|
_BUNDLE_GIT_GATE_PORT = 9418
|
|
_BUNDLE_SUPERVISE_PORT = 9100
|
|
|
|
|
|
def resolve_plan(
|
|
spec: BottleSpec, *, stage_dir: Path
|
|
) -> SmolmachinesBottlePlan:
|
|
"""Materialize the smolmachines plan. The bundle's docker
|
|
subnet + pinned IP are derived from the slug; the agent's
|
|
`.smolmachine` artifact is built (or cache-hit) here so
|
|
launch's `machine create --from` boots without a registry
|
|
pull. Per-bottle guest env + the TSI allow_cidrs land on the
|
|
plan for launch to pass straight through to
|
|
`machine create` flags."""
|
|
smolmachines_preflight()
|
|
|
|
manifest = spec.manifest
|
|
bottle = manifest.bottle_for(spec.agent_name)
|
|
|
|
slug = spec.identity or bottle_identity(spec.agent_name)
|
|
|
|
# Record minimal metadata so `cli.py resume` can recover the
|
|
# slug. Same schema as the docker backend.
|
|
write_metadata(BottleMetadata(
|
|
identity=slug,
|
|
agent_name=spec.agent_name,
|
|
cwd=spec.user_cwd if spec.copy_cwd else "",
|
|
copy_cwd=spec.copy_cwd,
|
|
started_at=datetime.now(timezone.utc).isoformat(),
|
|
# No compose project for smolmachines bottles; chunk 4
|
|
# will give dashboard discovery a backend-specific path.
|
|
compose_project="",
|
|
))
|
|
|
|
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
|
|
|
|
# Agent's env. IP literals; no DNS resolution inside the guest
|
|
# (TSI allowlist contains only `<bundle_ip>/32` — no resolver).
|
|
guest_env: dict[str, str] = {
|
|
**bottle.env,
|
|
"HTTPS_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}",
|
|
"HTTP_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}",
|
|
"NO_PROXY": "localhost,127.0.0.1",
|
|
}
|
|
if bottle.git:
|
|
guest_env["GIT_GATE_URL"] = (
|
|
f"git://{bundle_ip}:{_BUNDLE_GIT_GATE_PORT}"
|
|
)
|
|
if bottle.supervise:
|
|
guest_env["MCP_SUPERVISE_URL"] = (
|
|
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
|
|
)
|
|
|
|
machine_name = f"claude-bottle-{slug}"
|
|
# Chunk 2d placeholder until chunk 4's agent-image work lands.
|
|
# alpine pulls cleanly from docker.io via smolvm's crane
|
|
# backend; the real claude-bottle image lives in the local
|
|
# docker daemon and isn't reachable that way.
|
|
agent_image_ref = "alpine:latest"
|
|
agent_from_path = _ensure_smolmachine(agent_image_ref)
|
|
|
|
return SmolmachinesBottlePlan(
|
|
spec=spec,
|
|
stage_dir=stage_dir,
|
|
slug=slug,
|
|
bundle_subnet=subnet,
|
|
bundle_gateway=gateway,
|
|
bundle_ip=bundle_ip,
|
|
machine_name=machine_name,
|
|
agent_from_path=agent_from_path,
|
|
guest_env=guest_env,
|
|
)
|
|
|
|
|
|
def _ensure_smolmachine(image_ref: str) -> Path:
|
|
"""Cache `smolvm pack create --image <image_ref>` output under
|
|
`~/.cache/claude-bottle/smolmachines/<slug>`. Returns the
|
|
`.smolmachine.smolmachine` sidecar path — that's the file
|
|
`machine create --from` consumes (pack create produces a
|
|
launcher binary at `.smolmachine` plus the sidecar alongside
|
|
it; the sidecar is the actual artifact).
|
|
|
|
Re-runs of pack create against the same image hit smolvm's
|
|
layer cache; we still skip the call entirely when the
|
|
sidecar is already on disk, since each invocation costs
|
|
several seconds even on a hot cache."""
|
|
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
slug = image_ref.replace(":", "_").replace("/", "_")
|
|
binary = _SMOLMACHINE_CACHE_DIR / f"{slug}.smolmachine"
|
|
sidecar = _SMOLMACHINE_CACHE_DIR / f"{slug}.smolmachine.smolmachine"
|
|
if not sidecar.is_file():
|
|
_smolvm.pack_create(image_ref, binary)
|
|
return sidecar
|