feat(smolmachines): end-to-end launch + Bottle.exec + smoke + probes (PRD 0023 chunk 2d)
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>
This commit was merged in pull request #67.
This commit is contained in:
@@ -1,41 +1,66 @@
|
||||
"""SmolmachinesBottle — runtime handle stub (PRD 0023 chunk 1).
|
||||
"""SmolmachinesBottle — running-instance handle (PRD 0023 chunk 2d).
|
||||
|
||||
The chunk-1 backend doesn't launch VMs yet, so this class only
|
||||
exists to make `SmolmachinesBottleBackend.launch` resolvable at
|
||||
import time. Every method raises NotImplementedError; chunk 2
|
||||
gives it real `smolvm machine exec` plumbing."""
|
||||
Routes `exec_claude` / `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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .. import Bottle, ExecResult
|
||||
from . import smolvm as _smolvm
|
||||
|
||||
|
||||
class SmolmachinesBottle(Bottle):
|
||||
"""Stub. Real impl lands in chunk 2."""
|
||||
"""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, name: str) -> None:
|
||||
self.name = name
|
||||
def __init__(self, machine_name: str) -> None:
|
||||
self.name = machine_name
|
||||
|
||||
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
del argv, tty
|
||||
raise NotImplementedError(
|
||||
"smolmachines backend chunk 1 ships prepare-only; "
|
||||
"exec_claude lands in chunk 2"
|
||||
)
|
||||
"""Run `claude` interactively inside the VM. Inherits the
|
||||
operator's terminal (stdin / stdout / stderr) so the
|
||||
session feels native. Blocks until claude 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."""
|
||||
flags = ["smolvm", "machine", "exec", "--name", self.name]
|
||||
if tty:
|
||||
flags += ["-i", "-t"]
|
||||
flags += ["--", "claude", *argv]
|
||||
result = subprocess.run(flags, check=False)
|
||||
return result.returncode
|
||||
|
||||
def exec(self, script: str) -> ExecResult:
|
||||
del script
|
||||
raise NotImplementedError(
|
||||
"smolmachines backend chunk 1 ships prepare-only; "
|
||||
"exec lands in chunk 2"
|
||||
"""Run a POSIX shell script and capture the result. The
|
||||
script runs under `/bin/sh -c`, matching what the docker
|
||||
backend's `exec` does — callers can write shell-y test
|
||||
helpers without worrying about argv splitting."""
|
||||
r = _smolvm.machine_exec(
|
||||
self.name,
|
||||
["/bin/sh", "-c", script],
|
||||
)
|
||||
return ExecResult(
|
||||
returncode=r.returncode,
|
||||
stdout=r.stdout,
|
||||
stderr=r.stderr,
|
||||
)
|
||||
|
||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
||||
del host_path, container_path
|
||||
raise NotImplementedError(
|
||||
"smolmachines backend chunk 1 ships prepare-only; "
|
||||
"cp_in lands in chunk 2"
|
||||
)
|
||||
"""Copy a host path into the guest at `container_path`."""
|
||||
_smolvm.machine_cp(host_path, f"{self.name}:{container_path}")
|
||||
|
||||
def close(self) -> None:
|
||||
# Real teardown lives on the launch ExitStack; this is just
|
||||
# the idempotent alias the BottleBackend ABC expects.
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user