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,101 @@
|
||||
"""gvproxy YAML config renderer (PRD 0023).
|
||||
|
||||
The gvproxy config defines:
|
||||
|
||||
- The per-bottle subnet + gateway IP — derived from the slug so
|
||||
parallel bottles don't collide on 192.168.X.0/24.
|
||||
- A DNS rule that resolves only `proxy.internal` to the gateway.
|
||||
Every other hostname returns NXDOMAIN. This is the load-bearing
|
||||
rule for PRD 0022's DNS-exfil attack: the guest can't `dig`
|
||||
arbitrary names.
|
||||
- `port_forwards` — one entry per sidecar daemon the bottle uses.
|
||||
Only what's listed here is reachable from the guest. The
|
||||
host-side sidecar bundle listens on the resolved `host_port`s;
|
||||
gvproxy port-forwards `gateway_port` (what the guest dials) →
|
||||
host `host_port`.
|
||||
|
||||
This is the file the PRD 0023 design names as the network
|
||||
primitive. TSI is explicitly NOT used — see PRD 0023 "Why gvproxy,
|
||||
not TSI".
|
||||
|
||||
The renderer is pure; disk writes happen in prepare.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortForward:
|
||||
"""One gvproxy port forward. The guest dials `gateway_port`; the
|
||||
host receives the connection at `127.0.0.1:host_port`."""
|
||||
gateway_port: int
|
||||
host_port: int
|
||||
|
||||
|
||||
def gvproxy_config_build(
|
||||
*,
|
||||
subnet: str,
|
||||
gateway: str,
|
||||
port_forwards: tuple[PortForward, ...],
|
||||
) -> dict[str, Any]:
|
||||
"""Build the gvproxy YAML config dict. The shape matches the
|
||||
recipe in `agent-vm-isolation.md` § "Full Setup": top-level
|
||||
`subnet`, `gateway`, `dns:` with the `proxy.internal` carve-out,
|
||||
and `port_forwards`."""
|
||||
return {
|
||||
"subnet": subnet,
|
||||
"gateway": gateway,
|
||||
"dns": [
|
||||
{
|
||||
"zone": ".",
|
||||
"records": [
|
||||
{"name": "proxy.internal", "ip": gateway},
|
||||
],
|
||||
},
|
||||
],
|
||||
"port_forwards": [
|
||||
{
|
||||
"gateway_port": p.gateway_port,
|
||||
"host": "127.0.0.1",
|
||||
"host_port": p.host_port,
|
||||
}
|
||||
for p in port_forwards
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def gvproxy_config_render(cfg: dict[str, Any]) -> str:
|
||||
"""Render the gvproxy config as a small YAML subset (the same
|
||||
shape `pipelock_render_yaml` uses). Stdlib has no YAML writer
|
||||
and the config shape is narrow — strings + ints + lists of dicts
|
||||
— so rendering by hand is straightforward and keeps the project
|
||||
stdlib-only."""
|
||||
lines: list[str] = []
|
||||
lines.append(f'subnet: "{cfg["subnet"]}"')
|
||||
lines.append(f'gateway: "{cfg["gateway"]}"')
|
||||
lines.append("dns:")
|
||||
for zone in cfg["dns"]:
|
||||
lines.append(f' - zone: "{zone["zone"]}"')
|
||||
lines.append(" records:")
|
||||
for record in zone["records"]:
|
||||
lines.append(f' - name: "{record["name"]}"')
|
||||
lines.append(f' ip: "{record["ip"]}"')
|
||||
if cfg["port_forwards"]:
|
||||
lines.append("port_forwards:")
|
||||
for pf in cfg["port_forwards"]:
|
||||
lines.append(f' - gateway_port: {pf["gateway_port"]}')
|
||||
lines.append(f' host: "{pf["host"]}"')
|
||||
lines.append(f' host_port: {pf["host_port"]}')
|
||||
else:
|
||||
lines.append("port_forwards: []")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def gvproxy_config_write(cfg: dict[str, Any], path: Path) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(gvproxy_config_render(cfg))
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
Reference in New Issue
Block a user