feat(smolmachines): rewrite Smolfile to smolvm 0.8.0 schema + drop gvproxy (PRD 0023 chunk 2a)
test / unit (pull_request) Successful in 21s
test / integration (pull_request) Successful in 39s

First sub-PR of chunk 2: rewrite the renderer chunk 1 shipped to
match smolvm 0.8.0's actual Smolfile shape, delete the dead
gvproxy renderer + its tests, simplify the prepare flow now that
there's no gvproxy socket + no loopback-port allocation.

Smolfile renderer:
- Old shape (under the abandoned gvproxy design): name = ...,
  command = [...], [[net]] attachment = "unixgram",
  socket = "...".
- New shape (smolvm 0.8.0): env = [...] (sorted K=V pairs),
  [network] allow_cidrs = ["<bundle-ip>/32"]. Nothing else.
  image / entrypoint / cmd come from the .smolmachine artifact
  built in chunk 2b; cpus / memory left at smolvm defaults.
- Tests assert no leakage of TSI's --outbound-localhost-only or
  the old gvproxy/unixgram keys.

util.py:
- smolmachines_gvproxy_subnet → smolmachines_bundle_subnet,
  returning (subnet, gateway, bundle_ip). bundle_ip is always
  at .2 (gateway .1); subnet is /24, third octet derived from
  the slug hash, skipping the docker-default 17 to avoid the
  common 192.168.17.x collision.
- allocate_loopback_port: deleted. The bundle gets a pinned
  docker IP now; the agent dials that IP directly through TSI.
- smolmachines_preflight: dropped the gvproxy check; only
  smolvm is required.

prepare.py:
- Drops the gvproxy.yaml render + the loopback port allocation
  + the gvproxy_socket field on the plan.
- Derives subnet / gateway / bundle_ip from the slug and
  populates the new SmolmachinesBottlePlan fields.
- Agent env now uses IP-literal URLs (http://<bundle-ip>:8888
  etc) since the guest will have no DNS resolver inside TSI's
  allowlist.

bottle_plan.py:
- Old fields: gvproxy_config_path, gvproxy_socket,
  gvproxy_subnet, gvproxy_gateway, host_port_map.
- New fields: bundle_subnet, bundle_gateway, bundle_ip,
  smolfile_path. (smolmachine artifact path lands in chunk 2b.)

Net: -410 lines. Full unit suite: 516 passing.

The VM lifecycle + bundle bringup + launch wiring + smoke tests
land in chunk 2b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 04:01:07 -04:00
parent b57256789f
commit c73d717f71
8 changed files with 203 additions and 613 deletions
+58 -69
View File
@@ -1,17 +1,14 @@
"""Unit: Smolfile renderer for the smolmachines backend (PRD 0023).
Pure-function tests on `smolfile_build` + `smolfile_render`. The
schema we emit is narrow (name + command + env + one inline-table
per net device), so the tests exhaustively cover what lands on
disk."""
schema we emit is narrow (env list + `[network] allow_cidrs`), so
the tests exhaustively cover what lands on disk."""
from __future__ import annotations
import unittest
from pathlib import Path
from claude_bottle.backend.smolmachines.smolfile import (
GVPROXY_PIPELOCK_GATEWAY_PORT,
smolfile_build,
smolfile_render,
)
@@ -20,104 +17,96 @@ from claude_bottle.backend.smolmachines.smolfile import (
class TestSmolfileBuild(unittest.TestCase):
def _build(self, **kwargs):
defaults = dict(
slug="demo-abc12",
gvproxy_socket=Path("/tmp/cb-stage/gvproxy.sock"),
env={"HTTPS_PROXY": "http://proxy.internal:8888"},
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
bundle_ip="192.168.50.2",
)
defaults.update(kwargs)
return smolfile_build(**defaults)
def test_name_uses_claude_bottle_prefix(self):
cfg = self._build(slug="myagent-xyz")
self.assertEqual("claude-bottle-myagent-xyz", cfg["name"])
def test_command_defaults_to_sleep_infinity(self):
# Chunk 1 placeholder; chunk 4 swaps in the real claude
# entrypoint.
cfg = self._build()
self.assertEqual(["sleep", "infinity"], cfg["command"])
def test_command_can_be_overridden(self):
cfg = self._build(command=("claude", "--no-banner"))
self.assertEqual(["claude", "--no-banner"], cfg["command"])
def test_env_renders_as_sorted_KEY_VALUE_list(self):
# Sorted by key so renderer output is deterministic.
cfg = self._build(env={
"ZED": "one",
"ALPHA": "two",
"HTTPS_PROXY": "http://proxy.internal:8888",
"HTTPS_PROXY": "http://192.168.50.2:8888",
})
# Sorted by key so renderer output is deterministic.
self.assertEqual(
["ALPHA=two", "HTTPS_PROXY=http://proxy.internal:8888", "ZED=one"],
[
"ALPHA=two",
"HTTPS_PROXY=http://192.168.50.2:8888",
"ZED=one",
],
cfg["env"],
)
def test_net_device_points_at_gvproxy_socket(self):
cfg = self._build(gvproxy_socket=Path("/state/foo/gv.sock"))
self.assertEqual(1, len(cfg["net"]))
net = cfg["net"][0]
self.assertEqual("virtio-net", net["type"])
self.assertEqual("unixgram", net["attachment"])
self.assertEqual("/state/foo/gv.sock", net["socket"])
def test_allow_cidrs_is_single_slash_32(self):
# TSI's single-IP allowlist. Anything else would
# re-introduce the loopback / LAN reachability the PRD
# design carefully avoids.
cfg = self._build(bundle_ip="10.20.30.40")
self.assertEqual(
{"allow_cidrs": ["10.20.30.40/32"]},
cfg["network"],
)
def test_no_tsi_flags(self):
# PRD 0023: TSI is explicitly rejected. The Smolfile must
# never carry --allow-cidr / --allow-host /
# --outbound-localhost-only — gvproxy is the policy layer.
def test_no_image_or_command_emitted(self):
# The chunk-1 renderer (under the abandoned gvproxy design)
# emitted `name = ...` + `[[net]] attachment="unixgram"`.
# The new renderer carries only the per-bottle overrides;
# image / entrypoint / cmd come from the .smolmachine
# artifact, not the Smolfile.
cfg = self._build()
rendered = smolfile_render(cfg)
self.assertNotIn("--allow-cidr", rendered)
self.assertNotIn("--allow-host", rendered)
self.assertNotIn("--outbound-localhost-only", rendered)
self.assertNotIn("tsi", rendered.lower())
self.assertNotIn("image", cfg)
self.assertNotIn("entrypoint", cfg)
self.assertNotIn("cmd", cfg)
self.assertNotIn("command", cfg)
self.assertNotIn("name", cfg)
class TestSmolfileRender(unittest.TestCase):
"""The rendered TOML must be parseable by stdlib `tomllib` and
contain the keys the smolmachines schema expects."""
def _render(self, **kwargs):
cfg = smolfile_build(
slug="demo-abc12",
gvproxy_socket=Path("/tmp/gvp.sock"),
env={"HTTPS_PROXY": "http://proxy.internal:8888"},
**kwargs,
defaults = dict(
env={"HTTPS_PROXY": "http://192.168.50.2:8888"},
bundle_ip="192.168.50.2",
)
return smolfile_render(cfg)
defaults.update(kwargs)
return smolfile_render(smolfile_build(**defaults))
def test_round_trip_through_tomllib(self):
import tomllib # stdlib in 3.11+
rendered = self._render()
parsed = tomllib.loads(rendered)
self.assertEqual("claude-bottle-demo-abc12", parsed["name"])
self.assertEqual(["sleep", "infinity"], parsed["command"])
self.assertIn("HTTPS_PROXY=http://proxy.internal:8888", parsed["env"])
# net is an array of tables → list of dicts post-parse.
self.assertEqual(1, len(parsed["net"]))
self.assertEqual("/tmp/gvp.sock", parsed["net"][0]["socket"])
self.assertIn(
"HTTPS_PROXY=http://192.168.50.2:8888",
parsed["env"],
)
self.assertEqual(
["192.168.50.2/32"],
parsed["network"]["allow_cidrs"],
)
def test_special_chars_in_values_escape_correctly(self):
def test_no_tsi_outbound_localhost_only(self):
# Whole point of the design pivot: never emit
# `--outbound-localhost-only` or similar that would
# re-open host loopback.
text = self._render()
self.assertNotIn("outbound_localhost_only", text)
self.assertNotIn("outbound-localhost-only", text)
# And no gvproxy / virtio-net carve-out leaked from the
# abandoned first draft.
self.assertNotIn("unixgram", text)
self.assertNotIn("gvproxy", text.lower())
def test_special_chars_in_env_value_escape(self):
import tomllib
cfg = smolfile_build(
slug="demo",
gvproxy_socket=Path("/tmp/path with spaces/gv.sock"),
env={"WITH_QUOTES": 'has "double" quotes'},
bundle_ip="10.0.0.1",
)
rendered = smolfile_render(cfg)
parsed = tomllib.loads(rendered)
self.assertEqual(
"/tmp/path with spaces/gv.sock",
parsed["net"][0]["socket"],
)
# The env entry survives the quote escape.
self.assertIn('WITH_QUOTES=has "double" quotes', parsed["env"])
def test_constants_match_what_prepare_uses(self):
# Lock the gateway-port constant so the prepare side and the
# config-render side don't drift out of sync.
self.assertEqual(8888, GVPROXY_PIPELOCK_GATEWAY_PORT)
if __name__ == "__main__":
unittest.main()
@@ -1,117 +0,0 @@
"""Unit: gvproxy YAML renderer for the smolmachines backend
(PRD 0023). The config shape comes from the recipe in
`docs/research/agent-vm-isolation.md` § "Full Setup". Tests pin
the load-bearing rules: only `proxy.internal` resolves; only
explicit port forwards are reachable."""
from __future__ import annotations
import unittest
from claude_bottle.backend.smolmachines.gvproxy_config import (
PortForward,
gvproxy_config_build,
gvproxy_config_render,
)
class TestGvproxyConfigBuild(unittest.TestCase):
def test_subnet_and_gateway_pass_through(self):
cfg = gvproxy_config_build(
subnet="192.168.50.0/24",
gateway="192.168.50.1",
port_forwards=(),
)
self.assertEqual("192.168.50.0/24", cfg["subnet"])
self.assertEqual("192.168.50.1", cfg["gateway"])
def test_dns_resolves_only_proxy_internal(self):
# Load-bearing for PRD 0022's DNS-exfil attack: anything
# other than `proxy.internal` MUST return NXDOMAIN.
cfg = gvproxy_config_build(
subnet="192.168.50.0/24",
gateway="192.168.50.1",
port_forwards=(),
)
self.assertEqual(1, len(cfg["dns"]))
zone = cfg["dns"][0]
self.assertEqual(".", zone["zone"])
self.assertEqual(
[{"name": "proxy.internal", "ip": "192.168.50.1"}],
zone["records"],
)
def test_port_forwards_render_one_per_entry(self):
cfg = gvproxy_config_build(
subnet="192.168.50.0/24",
gateway="192.168.50.1",
port_forwards=(
PortForward(gateway_port=8888, host_port=51001),
PortForward(gateway_port=8889, host_port=51002),
PortForward(gateway_port=8890, host_port=51003),
),
)
self.assertEqual(3, len(cfg["port_forwards"]))
# All forwards land on host loopback.
for pf in cfg["port_forwards"]:
self.assertEqual("127.0.0.1", pf["host"])
def test_no_port_forwards_renders_empty_list(self):
# A bottle that somehow had no forwards (none in practice
# since pipelock is always allocated) must not silently
# default to permissive — explicit empty list keeps the
# guest with literally no outbound destinations.
cfg = gvproxy_config_build(
subnet="192.168.50.0/24",
gateway="192.168.50.1",
port_forwards=(),
)
self.assertEqual([], cfg["port_forwards"])
class TestGvproxyConfigRender(unittest.TestCase):
def _render(self, **kwargs):
defaults = dict(
subnet="192.168.50.0/24",
gateway="192.168.50.1",
port_forwards=(PortForward(gateway_port=8888, host_port=51001),),
)
defaults.update(kwargs)
return gvproxy_config_render(gvproxy_config_build(**defaults))
def test_subnet_and_gateway_quoted_strings(self):
text = self._render()
self.assertIn('subnet: "192.168.50.0/24"', text)
self.assertIn('gateway: "192.168.50.1"', text)
def test_dns_records_emit_in_yaml_list_form(self):
text = self._render()
self.assertIn('dns:', text)
self.assertIn('- zone: "."', text)
self.assertIn('- name: "proxy.internal"', text)
self.assertIn('ip: "192.168.50.1"', text)
def test_port_forwards_emit_inline_ints(self):
text = self._render(port_forwards=(
PortForward(gateway_port=8888, host_port=51001),
))
self.assertIn('- gateway_port: 8888', text)
self.assertIn('host_port: 51001', text)
self.assertIn('host: "127.0.0.1"', text)
def test_empty_port_forwards_uses_empty_list_syntax(self):
text = self._render(port_forwards=())
self.assertIn("port_forwards: []", text)
def test_no_tsi_or_allowlist_leak(self):
# gvproxy's job is the explicit port-forward allowlist. No
# mention of TSI primitives that the smolmachines research
# note recommended and PRD 0023 explicitly rejected.
text = self._render()
for forbidden in ("allow-cidr", "allow-host", "TSI", "tsi"):
self.assertNotIn(forbidden, text,
f"gvproxy config leaked {forbidden!r}")
if __name__ == "__main__":
unittest.main()
+33 -71
View File
@@ -1,117 +1,79 @@
"""Unit: smolmachines backend util helpers (PRD 0023 chunk 1)."""
"""Unit: smolmachines backend util helpers (PRD 0023)."""
from __future__ import annotations
import socket
import unittest
from unittest.mock import patch
from claude_bottle.backend.smolmachines.util import (
allocate_loopback_port,
smolmachines_gvproxy_subnet,
smolmachines_bundle_subnet,
smolmachines_preflight,
)
class TestGvproxySubnet(unittest.TestCase):
def test_returns_192_168_X_format(self):
subnet, gateway = smolmachines_gvproxy_subnet("demo-abc12")
class TestBundleSubnet(unittest.TestCase):
def test_returns_subnet_gateway_and_bundle_ip(self):
subnet, gateway, bundle_ip = smolmachines_bundle_subnet("demo-abc12")
self.assertTrue(subnet.startswith("192.168."))
self.assertTrue(subnet.endswith(".0/24"))
self.assertTrue(gateway.startswith("192.168."))
# Gateway at .1, bundle at .2 — fixed convention.
self.assertTrue(gateway.endswith(".1"))
# The subnet and gateway share the same third octet.
sub_octet = subnet.split(".")[2]
gw_octet = gateway.split(".")[2]
self.assertEqual(sub_octet, gw_octet)
self.assertTrue(bundle_ip.endswith(".2"))
# All three share the same third octet.
third = subnet.split(".")[2]
self.assertEqual(third, gateway.split(".")[2])
self.assertEqual(third, bundle_ip.split(".")[2])
def test_stable_for_same_slug(self):
# Recoverability: `resume` reuses the slug + expects the
# same subnet so a re-attach doesn't try to grab a fresh
# network range from gvproxy.
a = smolmachines_gvproxy_subnet("demo-abc12")
b = smolmachines_gvproxy_subnet("demo-abc12")
# Recoverability: `cli.py resume` reuses the slug and
# expects to find the same per-bottle subnet (a fresh
# docker bridge would mean a different IP, and smolvm's
# allow_cidrs would no longer match).
a = smolmachines_bundle_subnet("demo-abc12")
b = smolmachines_bundle_subnet("demo-abc12")
self.assertEqual(a, b)
def test_different_slugs_likely_differ(self):
# Not a guarantee (it's hash-mod-254 so collisions exist),
# Not a guarantee it's hash-mod-254, collisions exist
# but two arbitrary slugs shouldn't share a subnet in the
# typical case.
seen = {
smolmachines_gvproxy_subnet(s)
smolmachines_bundle_subnet(s)[0]
for s in ("a", "b", "c", "d", "e", "alpha", "beta", "gamma")
}
self.assertGreater(len(seen), 1)
def test_never_collides_with_docker_default_bridge(self):
# docker's default bridge sits at 172.17.x.x but operators
# commonly also see 192.168.17.x from VPN clients on macOS.
# The util explicitly skips octet 17 → 18 so the smolmachines
# subnet doesn't collide with that historical pain point.
def test_skips_docker_default_octet(self):
# docker's default bridge sits at 172.17.x.x; operators
# often also see 192.168.17.x from VPN clients on macOS.
# The util skips octet 17 → 18 so the smolmachines subnet
# doesn't collide with that historical pain point.
for slug in (f"slug-{i}" for i in range(500)):
subnet, gateway = smolmachines_gvproxy_subnet(slug)
subnet, _, _ = smolmachines_bundle_subnet(slug)
self.assertNotEqual("192.168.17.0/24", subnet,
f"slug {slug!r} landed on the skipped octet")
class TestAllocateLoopbackPort(unittest.TestCase):
def test_returns_in_ephemeral_range(self):
port = allocate_loopback_port()
# Linux ephemeral starts at 32768; macOS at 49152. Either
# way it's >1024, which is what matters.
self.assertGreater(port, 1024)
self.assertLess(port, 65536)
def test_port_is_free_at_return(self):
# The dance is bind-with-port-0 + getsockname + close. By
# the time we return, the kernel has the port back in the
# free pool. We confirm by binding it ourselves immediately
# (we'll race with anyone else who races for it; the
# race-window caveat lives in the docstring).
port = allocate_loopback_port()
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind(("127.0.0.1", port))
finally:
s.close()
def test_multiple_calls_return_distinct_ports(self):
# The kernel rotates ephemeral ports; consecutive calls
# almost certainly land different ports.
ports = {allocate_loopback_port() for _ in range(8)}
self.assertGreater(len(ports), 1)
class TestPreflight(unittest.TestCase):
def test_both_binaries_present_returns_none(self):
def test_smolvm_present_returns_none(self):
with patch(
"claude_bottle.backend.smolmachines.util.shutil.which",
side_effect=lambda name: f"/usr/local/bin/{name}",
return_value="/usr/local/bin/smolvm",
):
self.assertIsNone(smolmachines_preflight())
def test_missing_smolvm_dies_with_pointer(self):
def test_missing_smolvm_dies(self):
with patch(
"claude_bottle.backend.smolmachines.util.shutil.which",
side_effect=lambda name: None if name == "smolvm" else f"/x/{name}",
return_value=None,
):
with self.assertRaises(SystemExit) as cm:
smolmachines_preflight()
self.assertNotEqual(0, cm.exception.code)
def test_missing_gvproxy_dies_with_pointer(self):
with patch(
"claude_bottle.backend.smolmachines.util.shutil.which",
side_effect=lambda name: None if name == "gvproxy" else f"/x/{name}",
):
with self.assertRaises(SystemExit):
smolmachines_preflight()
def test_missing_both_lists_both_in_message(self):
# When both are gone, the message names both binaries and
# gives both install pointers — operator shouldn't have to
# re-run to discover the second missing dep.
import io, sys
def test_install_pointer_in_error(self):
import io
import sys
with patch(
"claude_bottle.backend.smolmachines.util.shutil.which",
return_value=None,
@@ -122,7 +84,7 @@ class TestPreflight(unittest.TestCase):
smolmachines_preflight()
msg = captured.getvalue()
self.assertIn("smolvm", msg)
self.assertIn("gvproxy", msg)
self.assertIn("smolmachines.com/install.sh", msg)
if __name__ == "__main__":