feat(smolmachines): backend skeleton + Smolfile/gvproxy renderers (PRD 0023 chunk 1)
test / unit (pull_request) Successful in 22s
test / integration (pull_request) Successful in 43s

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:
2026-05-27 02:22:08 -04:00
parent bce1ea21db
commit 20f411b22e
13 changed files with 1054 additions and 0 deletions
+2
View File
@@ -281,6 +281,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
# via `from . import ...` without hitting a partially-initialized module.
from .docker import DockerBottleBackend # noqa: E402
from .smolmachines import SmolmachinesBottleBackend # noqa: E402
# The dict is heterogeneous: each value is a BottleBackend specialized
@@ -289,6 +290,7 @@ from .docker import DockerBottleBackend # noqa: E402
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
"docker": DockerBottleBackend(),
"smolmachines": SmolmachinesBottleBackend(),
}
@@ -0,0 +1,15 @@
"""smolmachines bottle backend (PRD 0023).
Selectable via `CLAUDE_BOTTLE_BACKEND=smolmachines`. Runs each
bottle inside a per-agent microVM (libkrun / Hypervisor.framework
on macOS) with a userspace gvproxy gateway as the egress
primitive. The sidecar bundle (PRD 0024) runs as a host-side
docker container reached only through gvproxy's port-forward list.
Chunk 1 (this commit) ships the backend skeleton + Smolfile +
gvproxy renderers + preflight check. VM lifecycle, sidecar
bringup, and provisioning land in later chunks."""
from .backend import SmolmachinesBottleBackend # noqa: F401
__all__ = ["SmolmachinesBottleBackend"]
@@ -0,0 +1,76 @@
"""SmolmachinesBottleBackend — the smolmachines implementation of
BottleBackend (PRD 0023). Chunk 1 ships prepare-only; launch raises
NotImplementedError until chunk 2."""
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
from typing import Generator
from .. import BottleBackend, BottleSpec
from . import prepare as _prepare
from .bottle import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
from .bottle_plan import SmolmachinesBottlePlan
class SmolmachinesBottleBackend(
BottleBackend["SmolmachinesBottlePlan", "SmolmachinesBottleCleanupPlan"]
):
"""smolmachines backend. Selected by
`CLAUDE_BOTTLE_BACKEND=smolmachines`."""
name = "smolmachines"
def _resolve_plan(
self, spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
@contextmanager
def launch(
self, plan: SmolmachinesBottlePlan
) -> Generator[SmolmachinesBottle, None, None]:
del plan
raise NotImplementedError(
"smolmachines launch is implemented in PRD 0023 chunk 2; "
"chunk 1 ships prepare-only (the Smolfile + gvproxy "
"config are written, but no VM is brought up)."
)
# The generator never gets here, but the type checker wants
# to see the yield:
yield # type: ignore[unreachable]
# The four `provision_*` methods land in chunk 4 alongside the
# `smolvm machine exec`-based copy-in flow. Stubs raise so any
# caller that reaches them before chunk 4 gets a clear pointer.
def provision_prompt(
self, plan: SmolmachinesBottlePlan, target: str
) -> str | None:
raise NotImplementedError("smolmachines provision_prompt → chunk 4")
def provision_skills(
self, plan: SmolmachinesBottlePlan, target: str
) -> None:
raise NotImplementedError("smolmachines provision_skills → chunk 4")
def provision_git(
self, plan: SmolmachinesBottlePlan, target: str
) -> None:
raise NotImplementedError("smolmachines provision_git → chunk 4")
def prepare_cleanup(self) -> SmolmachinesBottleCleanupPlan:
return SmolmachinesBottleCleanupPlan()
def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None:
del plan
# Nothing to clean in chunk 1 — see SmolmachinesBottleCleanupPlan
# docstring.
def list_active(self) -> None:
from ...log import info
info(
"smolmachines list_active: not implemented (chunk 4 wires "
"it to `smolvm machine list`)"
)
@@ -0,0 +1,41 @@
"""SmolmachinesBottle — runtime handle stub (PRD 0023 chunk 1).
The chunk-1 backend doesn't launch VMs yet, so this class only
exists to make `SmolmachinesBottleBackend.launch` resolvable at
import time. Every method raises NotImplementedError; chunk 2
gives it real `smolvm machine exec` plumbing."""
from __future__ import annotations
from .. import Bottle, ExecResult
class SmolmachinesBottle(Bottle):
"""Stub. Real impl lands in chunk 2."""
def __init__(self, name: str) -> None:
self.name = name
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
del argv, tty
raise NotImplementedError(
"smolmachines backend chunk 1 ships prepare-only; "
"exec_claude lands in chunk 2"
)
def exec(self, script: str) -> ExecResult:
del script
raise NotImplementedError(
"smolmachines backend chunk 1 ships prepare-only; "
"exec lands in chunk 2"
)
def cp_in(self, host_path: str, container_path: str) -> None:
del host_path, container_path
raise NotImplementedError(
"smolmachines backend chunk 1 ships prepare-only; "
"cp_in lands in chunk 2"
)
def close(self) -> None:
pass
@@ -0,0 +1,25 @@
"""SmolmachinesBottleCleanupPlan — concrete BottleCleanupPlan stub
(PRD 0023 chunk 1).
Chunk 1 always reports nothing-to-clean. Real enumeration —
orphaned smolvm machines, stranded gvproxy sockets, leftover
sidecar bundle containers — lands in chunk 4 alongside the
integration-test sweep that exercises teardown."""
from __future__ import annotations
from dataclasses import dataclass
from ...log import info
from .. import BottleCleanupPlan
@dataclass(frozen=True)
class SmolmachinesBottleCleanupPlan(BottleCleanupPlan):
def print(self) -> None:
info("smolmachines cleanup: nothing to remove (chunk 4 will "
"enumerate orphan machines + gvproxy sockets)")
@property
def empty(self) -> bool:
return True
@@ -0,0 +1,64 @@
"""SmolmachinesBottlePlan — concrete BottlePlan for the smolmachines
backend (PRD 0023).
Chunk 1 fields: slug, smolfile_path, gvproxy_config_path, gvproxy
subnet + socket, and the per-bottle port map. VM lifecycle fields
(machine name, OCI archive path, etc.) land in later chunks as the
launch flow grows."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from ...log import info
from .. import BottlePlan
@dataclass(frozen=True)
class SmolmachinesBottlePlan(BottlePlan):
"""Resolved fields the launch step needs to bring up the bottle.
Inherits `spec` and `stage_dir` from BottlePlan."""
slug: str
smolfile_path: Path
gvproxy_config_path: Path
gvproxy_socket: Path
gvproxy_subnet: str
gvproxy_gateway: str
# Daemon name → host-side loopback port the bundle binds.
# Always includes "pipelock"; "git-gate" and "supervise"
# conditional on the bottle's manifest.
host_port_map: dict[str, int]
def print(self, *, remote_control: bool) -> None:
"""Compact y/N preflight for the smolmachines path. Mirrors
the docker preflight's layout so operators don't have to
learn two formats."""
del remote_control # not surfaced in the compact summary
spec = self.spec
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
info(f"backend: smolmachines")
info(f"agent: {spec.agent_name}")
info(f"bottle: {agent.bottle}")
info(f"slug: {self.slug}")
info(f"gvproxy: {self.gvproxy_gateway} on {self.gvproxy_subnet}")
env_names = sorted(bottle.env.keys())
skills = list(agent.skills)
upstreams = [g.Name for g in bottle.git]
routes = [r.host for r in bottle.egress.routes]
info(f"env: {', '.join(env_names) if env_names else '(none)'}")
info(f"skills: {', '.join(skills) if skills else '(none)'}")
info(f"git: {', '.join(upstreams) if upstreams else '(none)'}")
info(f"routes: {', '.join(routes) if routes else '(none)'}")
info(f"smolfile: {self.smolfile_path}")
info(f"gvproxy config: {self.gvproxy_config_path}")
info(
"(chunk 1 of PRD 0023: prepare-only — launch is "
"not yet implemented)"
)
@@ -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
@@ -0,0 +1,166 @@
"""smolmachines `_resolve_plan` (PRD 0023 chunk 1).
Lays down the two config files the launch step will consume —
Smolfile (TOML) and gvproxy YAML — under the bottle's stage dir.
No VM bringup. The plan it returns is enough for the y/N
preflight to render."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from ...backend import BottleSpec
from ...backend.docker.bottle_state import (
BottleMetadata,
bottle_identity,
write_metadata,
)
from ...backend.docker.sidecar_bundle import sidecar_bundle_container_name
from .bottle_plan import SmolmachinesBottlePlan
from .gvproxy_config import (
PortForward,
gvproxy_config_build,
gvproxy_config_write,
)
from .smolfile import (
GVPROXY_GIT_GATE_GATEWAY_PORT,
GVPROXY_PIPELOCK_GATEWAY_PORT,
GVPROXY_SUPERVISE_GATEWAY_PORT,
smolfile_build,
smolfile_write,
)
from .util import (
allocate_loopback_port,
smolmachines_gvproxy_subnet,
smolmachines_preflight,
)
def resolve_plan(
spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
"""Materialize the smolmachines plan. Three things land on disk
under stage_dir:
- `gvproxy.sock` path (created at launch time by gvproxy,
not by prepare — prepare only records where it'll go).
- `gvproxy.yaml` — subnet + DNS rule + port_forwards.
- `smolfile.toml` — guest command/env + virtio-net device
wired to the gvproxy unixgram socket.
The y/N preflight reads from the returned plan; chunk-2 launch
consumes the file paths it points at."""
smolmachines_preflight()
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
slug = spec.identity or bottle_identity(spec.agent_name)
# Record minimal metadata. `resume` (PRD 0016) reuses the slug
# via spec.identity, so the metadata file is the recoverability
# contract — keep it in lockstep with the docker backend's
# bottle_state schema.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
# No compose project for smolmachines bottles. Empty string
# so the dashboard's compose-project-based discovery skips
# these entries cleanly until chunk 4 teaches it about
# `smolvm machine list`.
compose_project="",
))
# Per-bottle gvproxy subnet + gateway. Deterministic from slug;
# collisions surface at launch time (chunk 2).
subnet, gateway = smolmachines_gvproxy_subnet(slug)
# Allocate one host-side loopback port per active sidecar
# daemon. The bundle (PRD 0024) binds these; gvproxy
# port-forwards from a fixed gateway port -> host port.
host_port_map: dict[str, int] = {
"pipelock": allocate_loopback_port(),
}
port_forwards: list[PortForward] = [PortForward(
gateway_port=GVPROXY_PIPELOCK_GATEWAY_PORT,
host_port=host_port_map["pipelock"],
)]
if bottle.git:
host_port_map["git-gate"] = allocate_loopback_port()
port_forwards.append(PortForward(
gateway_port=GVPROXY_GIT_GATE_GATEWAY_PORT,
host_port=host_port_map["git-gate"],
))
if bottle.supervise:
host_port_map["supervise"] = allocate_loopback_port()
port_forwards.append(PortForward(
gateway_port=GVPROXY_SUPERVISE_GATEWAY_PORT,
host_port=host_port_map["supervise"],
))
# Render + write the two config files.
gvproxy_socket = stage_dir / "gvproxy.sock"
gvproxy_yaml = stage_dir / "gvproxy.yaml"
smolfile = stage_dir / "smolfile.toml"
gvproxy_config_write(
gvproxy_config_build(
subnet=subnet,
gateway=gateway,
port_forwards=tuple(port_forwards),
),
gvproxy_yaml,
)
# Build the guest env. proxy_url points at the gvproxy gateway;
# gvproxy resolves `proxy.internal` to the gateway IP via its
# DNS rule, then port-forwards to the host sidecar bundle.
guest_env: dict[str, str] = {
**bottle.env,
"HTTPS_PROXY": f"http://proxy.internal:{GVPROXY_PIPELOCK_GATEWAY_PORT}",
"HTTP_PROXY": f"http://proxy.internal:{GVPROXY_PIPELOCK_GATEWAY_PORT}",
"NO_PROXY": "localhost,127.0.0.1",
}
if bottle.git:
guest_env["GIT_GATE_URL"] = (
f"git://proxy.internal:{GVPROXY_GIT_GATE_GATEWAY_PORT}"
)
if bottle.supervise:
guest_env["MCP_SUPERVISE_URL"] = (
f"http://proxy.internal:{GVPROXY_SUPERVISE_GATEWAY_PORT}"
)
smolfile_write(
smolfile_build(
slug=slug,
gvproxy_socket=gvproxy_socket,
env=guest_env,
),
smolfile,
)
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir,
slug=slug,
smolfile_path=smolfile,
gvproxy_config_path=gvproxy_yaml,
gvproxy_socket=gvproxy_socket,
gvproxy_subnet=subnet,
gvproxy_gateway=gateway,
host_port_map=host_port_map,
)
# Used by future cleanup logic — the bundle container that runs on
# the host carries this name even when its host is a smolmachines
# microVM. Re-exported here so chunk 3's sidecar-bringup path has
# a single import target.
__all__ = [
"resolve_plan",
"sidecar_bundle_container_name",
]
@@ -0,0 +1,111 @@
"""Smolfile (TOML) renderer for the smolmachines backend (PRD 0023).
The Smolfile pins the per-bottle microVM's command + env + virtio-net
device. Three fields drive what we emit:
- `command` — the entrypoint claude-bottle runs inside the guest.
Chunk 1 ships a placeholder (`sleep infinity`); chunk 4 wires
the real `claude` entrypoint once provisioning is in place.
- `env` — the agent's HTTP_PROXY / NO_PROXY / CA paths, pointing
at `proxy.internal:<gateway-port>`. gvproxy resolves
`proxy.internal` to the gateway IP and port-forwards to the
host-side sidecar bundle.
- `[[net]]` — a virtio-net device backed by gvproxy's unixgram
socket via the VFKT handshake. This is the line that rejects
libkrun's TSI mode: TSI's CIDR allowlist permits the entire
127.0.0.0/8 of host loopback, which exposes every host-side
service; gvproxy's explicit port-forward list is the only thing
the guest can reach.
The renderer is a pure function. Disk writes happen in
`prepare.py` via `smolfile_write`."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Mapping
# Default port assignments INSIDE the gvproxy network — what the
# guest dials. The agent's HTTPS_PROXY etc. resolve to
# `proxy.internal:<one of these>`. Host-side mapping is dynamic
# (chunk 3 allocates loopback ports per bottle).
GVPROXY_PIPELOCK_GATEWAY_PORT = 8888
GVPROXY_GIT_GATE_GATEWAY_PORT = 8889
GVPROXY_SUPERVISE_GATEWAY_PORT = 8890
def smolfile_build(
*,
slug: str,
gvproxy_socket: Path,
env: Mapping[str, str],
command: tuple[str, ...] = ("sleep", "infinity"),
) -> dict[str, Any]:
"""Build the Smolfile config dict.
`gvproxy_socket` is the unixgram socket gvproxy listens on; the
guest's virtio-net device handshakes (VFKT magic) with it on
start. `env` is `{NAME: VALUE}` for the guest's process env.
`command` is the entrypoint argv inside the guest (placeholder
until chunk 4 — see module docstring).
Returns a TOML-shaped dict; render with `smolfile_render`."""
return {
"name": f"claude-bottle-{slug}",
"command": list(command),
"env": [f"{k}={v}" for k, v in sorted(env.items())],
"net": [
{
"type": "virtio-net",
"attachment": "unixgram",
"socket": str(gvproxy_socket),
},
],
}
def smolfile_render(cfg: dict[str, Any]) -> str:
"""Render the Smolfile dict as TOML. Stdlib has `tomllib` for
reading TOML but no writer; the smolmachines schema we emit is
narrow enough (string scalars + string lists + one inline table
per net device) to render by hand. Avoids a `tomli_w` runtime
dep and keeps the project stdlib-only."""
lines: list[str] = []
lines.append(f'name = {_toml_str(cfg["name"])}')
lines.append(f'command = {_toml_array(cfg["command"])}')
lines.append(f'env = {_toml_array(cfg["env"])}')
lines.append("")
for net in cfg.get("net", ()):
lines.append("[[net]]")
for key, value in net.items():
lines.append(f'{key} = {_toml_str(value)}')
lines.append("")
return "\n".join(lines).rstrip("\n") + "\n"
def smolfile_write(cfg: dict[str, Any], path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(smolfile_render(cfg))
path.chmod(0o600)
return path
def _toml_str(value: Any) -> str:
"""TOML basic string: double-quoted with backslash + double-quote
escapes. The smolmachines fields we emit (slugs, paths, env
pairs) are ASCII-safe; the escape table covers what's reachable."""
s = str(value)
s = s.replace("\\", "\\\\").replace('"', '\\"')
return f'"{s}"'
def _toml_array(values: list[Any]) -> str:
"""TOML inline array. Uses `_toml_str` so quoting is consistent;
the alternative would be `json.dumps(values)` which renders
identical text for ASCII-only lists, but going through the same
quoter is one less surprise on future inputs."""
return "[" + ", ".join(_toml_str(v) for v in values) + "]"
@@ -0,0 +1,84 @@
"""Slug / preflight helpers for the smolmachines backend (PRD 0023).
Backend-specific utilities the prepare/launch flow reaches for.
Kept in its own module so the renderers can be unit-tested without
importing the docker subprocess paths."""
from __future__ import annotations
import hashlib
import os
import shutil
import socket
from contextlib import closing
from ...log import die
def smolmachines_preflight() -> None:
"""Ensure the binaries the smolmachines backend shells out to are
on PATH. Called from the backend's `_resolve_plan` before any
file writes; gives the operator a clear pointer rather than a
cryptic FileNotFoundError later.
`smolvm` drives the libkrun microVM lifecycle. `gvproxy`
(gvisor-tap-vsock) is the userspace TCP/IP stack the guest's
virtio-net device hooks into."""
missing: list[tuple[str, str]] = []
if shutil.which("smolvm") is None:
missing.append((
"smolvm",
"brew install smolmachines-dev/smolmachines/smolmachines "
"# or build from https://smolmachines.com/",
))
if shutil.which("gvproxy") is None:
missing.append((
"gvproxy",
"go install "
"github.com/containers/gvisor-tap-vsock/cmd/gvproxy@latest "
"# requires Go + ensure $GOPATH/bin on PATH",
))
if not missing:
return
lines = [
f"CLAUDE_BOTTLE_BACKEND=smolmachines requires the following "
f"binar{'y' if len(missing) == 1 else 'ies'} on PATH:",
]
for name, install in missing:
lines.append(f" - {name} install: {install}")
die("\n".join(lines))
def smolmachines_gvproxy_subnet(slug: str) -> tuple[str, str]:
"""Derive a per-bottle subnet + gateway IP from the slug.
Returns `(subnet_cidr, gateway_ip)`. The third octet comes from
SHA-256 of the slug mod 254 (1..254, skipping 0 and 255 + the
127.x.x.x range) — collision-free for any reasonable concurrent-
bottle count and stable across `start` / `resume` of the same
bottle. Bottles that DO collide would fail at gvproxy bringup;
`launch.py` (chunk 2) detects the conflict and surfaces a clear
error rather than silently reusing another bottle's gateway."""
digest = hashlib.sha256(slug.encode("utf-8")).digest()
octet = (digest[0] % 254) + 1
# Skip docker-default 17 to dodge the usual second-bridge
# collision. Operators with docker's default bridge at
# 172.17.x.x — common Linux setup — would otherwise see a
# collision the moment they run a parallel docker bottle.
if octet == 17:
octet = 18
subnet = f"192.168.{octet}.0/24"
gateway = f"192.168.{octet}.1"
return subnet, gateway
def allocate_loopback_port() -> int:
"""Bind / release dance to grab a free TCP port on 127.0.0.1.
Race window: a parallel allocator could grab the same port
between our close and the bundle's bind. The window is small
enough that chunk 1 doesn't address it; chunk 3 will add a
retry loop if it shows up in practice."""
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])