Files
bot-bottle/tests/unit/test_smolmachines_gvproxy_config.py
T
didericis 20f411b22e
test / unit (pull_request) Successful in 22s
test / integration (pull_request) Successful in 43s
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>
2026-05-27 02:22:08 -04:00

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()