feat(smolmachines): backend skeleton + Smolfile/gvproxy renderers (PRD 0023 chunk 1)
Ships the smolmachines backend's prepare side: subpackage layout,
`_BACKENDS` registration under "smolmachines", preflight check
for `smolvm` + `gvproxy` on PATH, and the two config-file
renderers (Smolfile TOML + gvproxy YAML). Launch raises
NotImplementedError until chunk 2.
New module layout (mirrors backend/docker/):
claude_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle stub (NotImpl until ch2)
bottle_plan.py SmolmachinesBottlePlan + .print()
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
prepare.py resolve_plan: writes both config files
smolfile.py TOML renderer (stdlib, no tomli_w dep)
gvproxy_config.py YAML renderer (same shape as pipelock_yaml)
util.py preflight + per-slug subnet + loopback port
The renderers are pure functions. `resolve_plan` runs the
preflight, allocates one host-side loopback port per active
sidecar (pipelock always; git-gate / supervise conditional),
derives a per-slug gvproxy subnet (hash-mod-254, skipping the
docker-default 17), and writes:
- <stage>/gvproxy.yaml: subnet + DNS rule resolving only
`proxy.internal` + port_forwards (one per active sidecar).
- <stage>/smolfile.toml: guest command/env + virtio-net device
backed by gvproxy's unixgram socket. No TSI flags — see
PRD 0023 "Why gvproxy, not TSI".
The agent's HTTPS_PROXY etc. point at `proxy.internal:<gateway-
port>` so the guest dials through gvproxy. gvproxy resolves only
`proxy.internal` → the gateway IP, and forwards exactly the
listed ports to the host-side sidecar bundle (PRD 0024); every
other destination — host LAN, host loopback, public internet
directly — is unreachable by construction.
29 new unit tests covering renderer correctness, subnet
derivation stability + collision-avoidance, loopback port
allocation, and preflight error paths. Full unit suite: 532
passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""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."""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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"},
|
||||
)
|
||||
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):
|
||||
cfg = self._build(env={
|
||||
"ZED": "one",
|
||||
"ALPHA": "two",
|
||||
"HTTPS_PROXY": "http://proxy.internal:8888",
|
||||
})
|
||||
# Sorted by key so renderer output is deterministic.
|
||||
self.assertEqual(
|
||||
["ALPHA=two", "HTTPS_PROXY=http://proxy.internal: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_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.
|
||||
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())
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
return smolfile_render(cfg)
|
||||
|
||||
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"])
|
||||
|
||||
def test_special_chars_in_values_escape_correctly(self):
|
||||
import tomllib
|
||||
cfg = smolfile_build(
|
||||
slug="demo",
|
||||
gvproxy_socket=Path("/tmp/path with spaces/gv.sock"),
|
||||
env={"WITH_QUOTES": 'has "double" quotes'},
|
||||
)
|
||||
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()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Unit: smolmachines backend util helpers (PRD 0023 chunk 1)."""
|
||||
|
||||
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_preflight,
|
||||
)
|
||||
|
||||
|
||||
class TestGvproxySubnet(unittest.TestCase):
|
||||
def test_returns_192_168_X_format(self):
|
||||
subnet, gateway = smolmachines_gvproxy_subnet("demo-abc12")
|
||||
self.assertTrue(subnet.startswith("192.168."))
|
||||
self.assertTrue(subnet.endswith(".0/24"))
|
||||
self.assertTrue(gateway.startswith("192.168."))
|
||||
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)
|
||||
|
||||
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")
|
||||
self.assertEqual(a, b)
|
||||
|
||||
def test_different_slugs_likely_differ(self):
|
||||
# Not a guarantee (it's hash-mod-254 so collisions exist),
|
||||
# but two arbitrary slugs shouldn't share a subnet in the
|
||||
# typical case.
|
||||
seen = {
|
||||
smolmachines_gvproxy_subnet(s)
|
||||
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.
|
||||
for slug in (f"slug-{i}" for i in range(500)):
|
||||
subnet, gateway = smolmachines_gvproxy_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):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.util.shutil.which",
|
||||
side_effect=lambda name: f"/usr/local/bin/{name}",
|
||||
):
|
||||
self.assertIsNone(smolmachines_preflight())
|
||||
|
||||
def test_missing_smolvm_dies_with_pointer(self):
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.util.shutil.which",
|
||||
side_effect=lambda name: None if name == "smolvm" else f"/x/{name}",
|
||||
):
|
||||
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
|
||||
with patch(
|
||||
"claude_bottle.backend.smolmachines.util.shutil.which",
|
||||
return_value=None,
|
||||
):
|
||||
captured = io.StringIO()
|
||||
with patch.object(sys, "stderr", captured):
|
||||
with self.assertRaises(SystemExit):
|
||||
smolmachines_preflight()
|
||||
msg = captured.getvalue()
|
||||
self.assertIn("smolvm", msg)
|
||||
self.assertIn("gvproxy", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user