20f411b22e
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>
118 lines
4.3 KiB
Python
118 lines
4.3 KiB
Python
"""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()
|