Merge pull request 'feat(smolmachines): per-bottle loopback alias scopes TSI to single /32' (#76) from smolmachines-loopback-alias-scoping into main
test / unit (push) Successful in 29s
test / integration (push) Successful in 41s

This commit was merged in pull request #76.
This commit is contained in:
2026-05-27 18:08:02 -04:00
6 changed files with 666 additions and 59 deletions
+19 -13
View File
@@ -200,19 +200,25 @@ sidecar bundle still in Docker. Selected via
The integration tests run against whichever backend the env var The integration tests run against whichever backend the env var
selects and skip cleanly when its prerequisites are missing. selects and skip cleanly when its prerequisites are missing.
**Known limitation, v1:** smolvm's TSI uses macOS networking, and **One-time sudo on first launch (macOS):** smolmachines bottles
Docker Desktop's container IPs aren't reachable from macOS, so the each reserve a loopback alias from a pool (`127.0.0.16` ..
smolmachines bottle dials the sidecar bundle through host loopback `127.0.0.31`) and bind their bundle's port-forwards to it; the
port-forwards (`127.0.0.1:<random>`). TSI filters by IP only, so the first `./cli.py start` after each reboot prompts for sudo to add
allowlist is `127.0.0.1/32` — meaning the agent VM can reach **any missing aliases via `ifconfig lo0 alias`. Aliases persist until
service bound to macOS's loopback**, not just the bundle's published reboot; subsequent launches don't prompt. The agent's TSI
ports. Practical implication: while a smolmachines bottle is running, allowlist is the alias's `/32`, so each bottle can only reach
host-local dev services (postgres on 5432, dev servers, etc.) are its own bundle's published ports — not other bottles' ports,
reachable from inside the agent even if you intended them to be not other host loopback services (postgres, dev servers, etc.).
host-private. The docker backend keeps the bottle on a `--internal`
docker network and doesn't have this issue. A future revision will This enforcement requires a workaround for a smolvm 0.8.0 bug:
narrow this via a per-bottle loopback alias + host-side proxy (see the CLI's `--allow-cidr` flag is silently dropped when combined
PRD 0023's "loopback scoping" section). with `--from <smolmachine>`. The launcher patches smolvm's
persistent state DB
(`~/Library/Application Support/smolvm/server/smolvm.db`)
directly between `machine create` and `machine start` to set
the allowlist. The hack falls away automatically when smolvm
honors the flag upstream — see the `loopback_alias` module's
docstring for the investigation trail.
## Manifest ## Manifest
+54 -23
View File
@@ -50,6 +50,7 @@ from ..docker.pipelock import (
PIPELOCK_PORT as _PIPELOCK_PORT_STR, PIPELOCK_PORT as _PIPELOCK_PORT_STR,
pipelock_tls_init, pipelock_tls_init,
) )
from . import loopback_alias as _loopback
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle from .bottle import SmolmachinesBottle
@@ -76,7 +77,16 @@ def launch(
via the ExitStack.""" via the ExitStack."""
stack = ExitStack() stack = ExitStack()
try: try:
# 1. Per-bottle docker bridge. # 1. Reserve a loopback alias for this bottle. macOS only
# routes 127.0.0.1 by default; the per-bottle alias is
# what bundles the docker port-publishes and TSI allowlist
# against, so this bottle can't reach other bottles' (or
# other host services') ports on the loopback. Lazy
# sudo-driven on first use per boot. No-op on Linux.
_loopback.ensure_pool()
loopback_ip = _loopback.allocate(plan.slug)
# 2. Per-bottle docker bridge.
network = _bundle.bundle_network_name(plan.slug) network = _bundle.bundle_network_name(plan.slug)
_bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway) _bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway)
stack.callback(_bundle.remove_bundle_network, network) stack.callback(_bundle.remove_bundle_network, network)
@@ -112,21 +122,22 @@ def launch(
) )
# 3. Build the BundleLaunchSpec from the (now-resolved) # 3. Build the BundleLaunchSpec from the (now-resolved)
# inner Plans: daemon subset, env, bind-mounts. The spec's # inner Plans: daemon subset, env, bind-mounts, and the
# ports_to_publish list expands depending on which daemons # loopback alias to bind published ports against. The
# the agent needs to reach from the smolvm guest. # spec's ports_to_publish list expands depending on which
bundle_spec = _bundle_launch_spec(plan, network) # daemons the agent needs to reach from the smolvm guest.
bundle_spec = _bundle_launch_spec(plan, network, loopback_ip)
token_env = _resolve_token_env(plan, os.environ) token_env = _resolve_token_env(plan, os.environ)
_bundle.start_bundle(bundle_spec, env={**os.environ, **token_env}) _bundle.start_bundle(bundle_spec, env={**os.environ, **token_env})
stack.callback(_bundle.stop_bundle, plan.slug) stack.callback(_bundle.stop_bundle, plan.slug)
# 4. Discover the host-side ports docker assigned for the # 4. Discover the host-side ports docker assigned for the
# bundle's published container ports, and bind the # bundle's published container ports, and bind the
# agent's URLs to `127.0.0.1:<host port>`. Docker container # agent's URLs to `<loopback_ip>:<host port>`. Docker
# IPs (192.168.x.x in the daemon's bridge) aren't # container IPs (192.168.x.x in the daemon's bridge)
# reachable from the smolvm guest on macOS — TSI uses # aren't reachable from the smolvm guest on macOS — TSI
# macOS networking, and macOS sees the daemon's bridge # uses macOS networking, and macOS sees the daemon's
# via the published-port loopback forward only. # bridge via the published-port loopback forward only.
# #
# Proxy hop order matches the docker backend: when the # Proxy hop order matches the docker backend: when the
# bottle declares egress routes, the agent's first hop is # bottle declares egress routes, the agent's first hop is
@@ -140,29 +151,41 @@ def launch(
else: else:
agent_facing_port = _PIPELOCK_PORT agent_facing_port = _PIPELOCK_PORT
agent_facing_host_port = _bundle.bundle_host_port( agent_facing_host_port = _bundle.bundle_host_port(
plan.slug, agent_facing_port, plan.slug, agent_facing_port, host_ip=loopback_ip,
) )
agent_proxy_url = f"http://127.0.0.1:{agent_facing_host_port}" agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}"
agent_git_gate_host = "" agent_git_gate_host = ""
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
git_gate_host_port = _bundle.bundle_host_port( git_gate_host_port = _bundle.bundle_host_port(
plan.slug, _GIT_GATE_PORT, plan.slug, _GIT_GATE_PORT, host_ip=loopback_ip,
) )
agent_git_gate_host = f"127.0.0.1:{git_gate_host_port}" agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
agent_supervise_url = "" agent_supervise_url = ""
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
supervise_host_port = _bundle.bundle_host_port( supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT, plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
) )
agent_supervise_url = f"http://127.0.0.1:{supervise_host_port}/" agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
# Stamp the URLs onto the plan + guest_env. provision_git # Stamp the URLs onto the plan + guest_env. provision_git
# and provision_supervise read the plan fields; the agent # and provision_supervise read the plan fields; the agent
# reads guest_env on every exec_claude. # reads guest_env on every exec_claude.
#
# NO_PROXY has to include the per-bottle loopback alias —
# otherwise claude's HTTPS_PROXY catches direct calls to
# the supervise URL (`http://<alias>:<port>/`) and proxies
# them through egress, which has no route for the alias
# and rejects with "Failed to connect". The git-gate URL
# uses git://, not affected by HTTP_PROXY, so the alias
# only has to be in NO_PROXY for the MCP / supervise
# path. Append rather than overwrite so prepare.py's
# `localhost,127.0.0.1` baseline stays in place.
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
guest_env = { guest_env = {
**plan.guest_env, **plan.guest_env,
"HTTPS_PROXY": agent_proxy_url, "HTTPS_PROXY": agent_proxy_url,
"HTTP_PROXY": agent_proxy_url, "HTTP_PROXY": agent_proxy_url,
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
} }
if agent_git_gate_host: if agent_git_gate_host:
guest_env["GIT_GATE_URL"] = f"git://{agent_git_gate_host}" guest_env["GIT_GATE_URL"] = f"git://{agent_git_gate_host}"
@@ -178,18 +201,25 @@ def launch(
# 5. smolvm VM. --from carries the pre-packed .smolmachine # 5. smolvm VM. --from carries the pre-packed .smolmachine
# artifact (built by prepare); --allow-cidr + -e carry the # artifact (built by prepare); --allow-cidr + -e carry the
# per-bottle TSI allowlist + env. The allowlist is # per-bottle TSI allowlist + env. The allowlist is the
# `127.0.0.1/32` because every bundle daemon the agent # per-bottle loopback alias — narrowing it to one /32 keeps
# reaches is fronted by a host loopback port-forward. # the agent from reaching other host loopback services or
# Smolfile isn't usable here — smolvm 0.8.0 makes `--from` # other bottles' published ports. Smolfile isn't usable
# and `--smolfile` mutually exclusive. # here — smolvm 0.8.0 makes `--from` and `--smolfile`
# mutually exclusive.
_smolvm.machine_create( _smolvm.machine_create(
plan.machine_name, plan.machine_name,
from_path=plan.agent_from_path, from_path=plan.agent_from_path,
allow_cidrs=["127.0.0.1/32"], allow_cidrs=[f"{loopback_ip}/32"],
env=plan.guest_env, env=plan.guest_env,
) )
stack.callback(_smolvm.machine_delete, plan.machine_name) stack.callback(_smolvm.machine_delete, plan.machine_name)
# Workaround smolvm 0.8.0: `--allow-cidr` is silently
# dropped when combined with `--from`. Patch the persisted
# state DB to set the allowlist before start so the booted
# VM's TSI actually enforces. See loopback_alias's module
# docstring for the investigation that led here.
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
_smolvm.machine_start(plan.machine_name) _smolvm.machine_start(plan.machine_name)
stack.callback(_smolvm.machine_stop, plan.machine_name) stack.callback(_smolvm.machine_stop, plan.machine_name)
@@ -240,7 +270,7 @@ def launch(
def _bundle_launch_spec( def _bundle_launch_spec(
plan: SmolmachinesBottlePlan, network: str plan: SmolmachinesBottlePlan, network: str, loopback_ip: str,
) -> _bundle.BundleLaunchSpec: ) -> _bundle.BundleLaunchSpec:
"""Build a BundleLaunchSpec from the resolved inner Plans. """Build a BundleLaunchSpec from the resolved inner Plans.
@@ -345,6 +375,7 @@ def _bundle_launch_spec(
environment=tuple(env), environment=tuple(env),
volumes=tuple(volumes), volumes=tuple(volumes),
ports_to_publish=tuple(ports_to_publish), ports_to_publish=tuple(ports_to_publish),
publish_host_ip=loopback_ip,
) )
@@ -0,0 +1,254 @@
"""Per-bottle loopback alias allocation + TSI allowlist
enforcement (PRD 0023, follow-up to PR #74).
After the pivot to host-loopback port-forwards, the smolmachines
TSI allowlist was `127.0.0.1/32` — which meant the agent VM could
reach **any** service bound to macOS's loopback, not just the
bundle's published ports. Real downgrade from the docker
backend's `--internal` network isolation.
This module narrows the allowlist by allocating each bottle a
unique loopback alias (`127.0.0.16` .. `127.0.0.31`). The
bundle's port-forwards bind to that alias, and the alias's /32
is what TSI allows.
**Smolvm 0.8.0 quirk + workaround.** `smolvm machine create
--from <smolmachine> --net --allow-cidr X/32` silently drops the
flag — verified empirically that the agent process's allowlist
ends up `null` in smolvm's persistent state DB (`~/Library/
Application Support/smolvm/server/smolvm.db`, `vms` table,
`data` BLOB), and the booted VM reaches all of `127.0.0.0/8`
regardless of what we passed. Workaround: after machine_create,
open the SQLite DB and patch the row's `allowed_cidrs` field
directly. Smolvm reads the DB at machine_start, so the patched
value takes effect on boot. Tested: enforcement is real — the
guest's connect to a non-allowlisted IP fails with `Permission
denied`. Other paths we tried (machine update, stop-edit-
agent.config.json-restart, --smolfile, --image localhost:N/...)
were dead ends.
macOS only configures `127.0.0.1` on `lo0` by default; the
additional aliases require `sudo ifconfig lo0 alias`. We lazily
sudo-add the missing pool on first use per boot — the aliases
persist on `lo0` until reboot, so subsequent launches don't
prompt.
Linux native daemons share the host's network namespace; the
whole `127.0.0.0/8` is reachable by default and aliases are
unnecessary. The pool logic detects native-Linux and skips sudo
entirely; the DB patch is also gated on macOS.
Allocation is coordinated by inspecting running bundle
containers' published host IPs — each bottle's bundle owns the
alias appearing in its port bindings. The lowest-numbered free
alias gets handed to a new bottle."""
from __future__ import annotations
import json
import os
import platform
import re
import sqlite3
import subprocess
from pathlib import Path
from typing import Iterable
from ...log import die, info
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
# table holds one JSON BLOB per machine. The Linux path is
# different, but smolmachines is macOS-only in v1 (PRD 0023) so
# we hard-code this. If the file moves under us we'll see a
# clear FileNotFoundError; not worth defensive cross-platform
# detection until the backend actually needs Linux.
_SMOLVM_DB_PATH = (
Path.home()
/ "Library"
/ "Application Support"
/ "smolvm"
/ "server"
/ "smolvm.db"
)
# Sixteen aliases by default. Tunable for hosts that want more
# concurrent bottles (each bottle reserves one alias for its
# bundle bringup). The range is chosen to avoid the reserved
# 127.0.0.1/2/3 ports (1 is the default, 2 is sometimes used by
# CUPS, 3 by other macOS services) and stay well clear of
# 127.0.0.53 (systemd-resolved) and 127.0.0.54 (libvirt).
_POOL_START = 16
_POOL_END = 31 # inclusive
# Loopback aliases pool: 127.0.0.<start>..127.0.0.<end>.
def _pool_addresses() -> list[str]:
return [f"127.0.0.{i}" for i in range(_POOL_START, _POOL_END + 1)]
def _is_macos() -> bool:
return platform.system() == "Darwin"
def ensure_pool() -> None:
"""Make sure each address in the pool is up on `lo0`. Lazily
runs `sudo ifconfig lo0 alias <ip>/32 up` for missing entries
(sudo prompts once, then the aliases persist on lo0 until
reboot). No-op on non-macOS hosts."""
if not _is_macos():
return
missing = [ip for ip in _pool_addresses() if not _alias_present(ip)]
if not missing:
return
info(
f"smolmachines needs {len(missing)} loopback alias(es) on lo0 "
f"({', '.join(missing[:3])}{', ...' if len(missing) > 3 else ''}) "
f"to scope per-bottle TSI allowlists. sudo will prompt once; "
f"aliases persist until reboot."
)
for ip in missing:
result = subprocess.run(
["sudo", "-p", "claude-bottle (loopback alias): ",
"ifconfig", "lo0", "alias", f"{ip}/32", "up"],
check=False,
)
if result.returncode != 0:
die(
f"sudo ifconfig lo0 alias {ip} failed (exit "
f"{result.returncode}). Re-run with sudo available, "
f"or add manually: sudo ifconfig lo0 alias {ip}/32 up"
)
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
"""Patch smolvm's persistent VM-state DB to set the machine's
`allowed_cidrs` to the given list. Workaround for smolvm
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
Must run AFTER `smolvm machine create` (the row has to
exist) and BEFORE `smolvm machine start` (smolvm reads the
row on start; in-flight VMs don't pick up changes). Once
smolvm honors the CLI flag upstream this whole function is
redundant — flag-respecting create + remove this call from
launch.
No-op on non-macOS — the DB path differs and the Linux
smolmachines code path isn't exercised in v1."""
if not _is_macos():
return
if not _SMOLVM_DB_PATH.is_file():
die(
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
f"smolvm 0.8.0 expected? `smolvm --version` to check."
)
con = sqlite3.connect(str(_SMOLVM_DB_PATH))
try:
cur = con.cursor()
row = cur.execute(
"SELECT data FROM vms WHERE name = ?", (machine_name,),
).fetchone()
if row is None:
die(
f"smolvm DB has no row for machine {machine_name!r}"
f"machine_create must run before force_allowlist."
)
cfg = json.loads(row[0])
cfg["allowed_cidrs"] = list(allowed_cidrs)
# Write as BLOB (the column type smolvm uses) — passing a
# plain str makes sqlite store it as Text and smolvm then
# fails to read it.
cur.execute(
"UPDATE vms SET data = ? WHERE name = ?",
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
)
con.commit()
finally:
con.close()
def allocate(slug: str) -> str:
"""Pick the lowest-numbered alias from the pool not already
in use by a running smolmachines bundle. Bails when the pool
is exhausted — the caller should report the limit to the
operator. `slug` is logged for traceability; not otherwise
used (no on-disk reservation, allocation is purely
docker-state-driven).
On non-macOS the whole `127.0.0.0/8` is loopback by default;
`127.0.0.1` is fine to share and we skip the alias dance.
This still returns a deterministic address so launch.py's
callers don't have to branch on platform."""
if not _is_macos():
return "127.0.0.1"
in_use = _aliases_in_use()
for ip in _pool_addresses():
if ip not in in_use:
return ip
die(
f"smolmachines loopback alias pool exhausted "
f"({_POOL_END - _POOL_START + 1} aliases, all in use). "
f"Stop a running bottle (`smolvm machine ls --json`) or "
f"raise _POOL_END in loopback_alias.py."
)
return "" # unreachable; die() never returns
def _alias_present(ip: str) -> bool:
"""True iff `ifconfig lo0` shows `<ip>` as an inet address.
Exact-match — `127.0.0.1` shouldn't match `127.0.0.16`."""
result = subprocess.run(
["/sbin/ifconfig", "lo0"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return False
pattern = re.compile(rf"\binet {re.escape(ip)}\b")
return bool(pattern.search(result.stdout or ""))
def _aliases_in_use() -> set[str]:
"""Aliases already bound by another smolmachines bundle's
published-port mappings. We inspect every container whose
name matches the smolmachines bundle prefix and pull the
`HostIp` out of its port bindings."""
result = subprocess.run(
["docker", "ps", "--format", "{{.Names}}",
"--filter", "name=claude-bottle-sidecars-"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return set()
names = [n.strip() for n in (result.stdout or "").splitlines() if n.strip()]
in_use: set[str] = set()
for name in names:
in_use.update(_host_ips_for_container(name))
return in_use
def _host_ips_for_container(name: str) -> Iterable[str]:
"""Yield the `HostIp` values across all port bindings on
container `name`. A bundle binds three or four ports and
they all share the same HostIp, so callers can take any."""
result = subprocess.run(
["docker", "inspect", name,
"--format", "{{json .HostConfig.PortBindings}}"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
return ()
try:
bindings = json.loads(result.stdout or "{}")
except json.JSONDecodeError:
return ()
seen: set[str] = set()
for _port, mappings in (bindings or {}).items():
for m in mappings or []:
host_ip = m.get("HostIp") or ""
if host_ip:
seen.add(host_ip)
return seen
__all__ = ["allocate", "ensure_pool", "force_allowlist"]
@@ -70,13 +70,19 @@ class BundleLaunchSpec:
environment: Sequence[str] = field(default_factory=tuple) environment: Sequence[str] = field(default_factory=tuple)
# (host_path, container_path, read_only) bind mounts. # (host_path, container_path, read_only) bind mounts.
volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple) volumes: Sequence[tuple[str, str, bool]] = field(default_factory=tuple)
# Container ports to publish on the host's 127.0.0.1, random # Container ports to publish on `publish_host_ip`, random
# host-side port per entry. The smolvm guest's TSI talks via # host-side port per entry. The smolvm guest's TSI talks via
# macOS networking, so docker container IPs (192.168.x.x in # macOS networking, so docker container IPs (192.168.x.x in
# the daemon's bridge) aren't directly reachable from the # the daemon's bridge) aren't directly reachable from the
# guest — host-loopback port-forwards are. Egress's port # guest — host-loopback port-forwards are. Egress's port
# is bundle-internal and never published. # is bundle-internal and never published.
ports_to_publish: Sequence[int] = field(default_factory=tuple) ports_to_publish: Sequence[int] = field(default_factory=tuple)
# Loopback IP to bind published ports against. Per-bottle
# loopback aliases (`127.0.0.16` etc., added via sudo
# ifconfig lo0 alias) narrow the TSI allowlist so a bottle
# can't reach other bottles' (or other host services') ports
# via 127.0.0.1.
publish_host_ip: str = "127.0.0.1"
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None: def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
@@ -145,8 +151,10 @@ def start_bundle(spec: BundleLaunchSpec, *,
# Loopback-only host port-forwards — the smolvm guest's TSI # Loopback-only host port-forwards — the smolvm guest's TSI
# uses macOS networking, and macOS loopback is the only host # uses macOS networking, and macOS loopback is the only host
# surface that round-trips into Docker Desktop's daemon VM. # surface that round-trips into Docker Desktop's daemon VM.
# Binds to the per-bottle alias so TSI's IP-only allowlist
# narrows reachability to this bottle's bundle only.
for port in spec.ports_to_publish: for port in spec.ports_to_publish:
argv += ["-p", f"127.0.0.1::{port}"] argv += ["-p", f"{spec.publish_host_ip}::{port}"]
argv.append(spec.image) argv.append(spec.image)
result = subprocess.run( result = subprocess.run(
argv, capture_output=True, text=True, argv, capture_output=True, text=True,
@@ -159,13 +167,15 @@ def start_bundle(spec: BundleLaunchSpec, *,
) )
def bundle_host_port(slug: str, container_port: int) -> int: def bundle_host_port(
slug: str, container_port: int, *, host_ip: str = "127.0.0.1",
) -> int:
"""`docker port <bundle> <container_port>/tcp` → the random """`docker port <bundle> <container_port>/tcp` → the random
host-side port docker assigned. Called after `start_bundle` host-side port docker assigned for the binding on `host_ip`.
on each container port listed in `BundleLaunchSpec Called after `start_bundle` on each container port listed in
.ports_to_publish` so the launch step can build the agent's `BundleLaunchSpec.ports_to_publish` so the launch step can
HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in build the agent's HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in
`127.0.0.1:<host port>` form.""" `<host_ip>:<host port>` form."""
container = bundle_container_name(slug) container = bundle_container_name(slug)
result = subprocess.run( result = subprocess.run(
["docker", "port", container, f"{container_port}/tcp"], ["docker", "port", container, f"{container_port}/tcp"],
@@ -176,14 +186,22 @@ def bundle_host_port(slug: str, container_port: int) -> int:
f"docker port {container} {container_port}/tcp failed: " f"docker port {container} {container_port}/tcp failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}" f"{(result.stderr or '').strip() or '<no stderr>'}"
) )
# `127.0.0.1:54321\n` — rpartition on last colon gives the port. # Each line looks like `127.0.0.16:54321` — one per address
line = (result.stdout or "").splitlines()[0].strip() # family / host IP. Match on the expected host_ip prefix so
_, _, port_str = line.rpartition(":") # bottles bound to per-bottle aliases pick the right line.
try: for raw in (result.stdout or "").splitlines():
return int(port_str) line = raw.strip()
except ValueError: if line.startswith(f"{host_ip}:"):
die(f"unexpected `docker port` output: {line!r}") _, _, port_str = line.rpartition(":")
return -1 # unreachable; die() never returns try:
return int(port_str)
except ValueError:
die(f"unexpected `docker port` output: {line!r}")
die(
f"no port mapping on {host_ip} for {container} "
f"{container_port}/tcp; got: {(result.stdout or '').strip()!r}"
)
return -1 # unreachable; die() never returns
def stop_bundle(slug: str) -> None: def stop_bundle(slug: str) -> None:
+27 -7
View File
@@ -609,13 +609,33 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
port on host loopback (`-p 127.0.0.1::<port>`) and set TSI to port on host loopback (`-p 127.0.0.1::<port>`) and set TSI to
`127.0.0.1/32`. **This widens the TSI allowlist to anything `127.0.0.1/32`. **This widens the TSI allowlist to anything
bound to macOS's loopback** — postgres, dev servers, other bound to macOS's loopback** — postgres, dev servers, other
bottles' published ports, mDNSResponder, etc. The agent can't bottles' published ports, mDNSResponder, etc.
reach them by intent, but TSI can't filter by port. Follow-up
to scope back: bind each bottle's bundle ports on a per-bottle **Fix + smolvm 0.8.0 workaround.** Allocate each bottle a
loopback alias (e.g. `127.0.0.2` for bottle A, `127.0.0.3` for unique loopback alias (`127.0.0.16` .. `127.0.0.31`), bind
B) added via `ifconfig lo0 alias`, set TSI to that single /32. bundle port-forwards to it, set TSI's allowlist to that
Needs sudo for alias setup; a small daemon-or-script we ship alias's /32. The agent can only reach its own bundle; other
alongside the launcher could handle it. bottles' ports, host loopback services, and the internet are
all denied.
Smolvm 0.8.0 silently drops `--allow-cidr` when combined
with `--from <smolmachine>` (verified empirically:
`agent.config.json` shows `allowed_cidrs:null` despite the
flag). The launcher patches smolvm's persistent state DB
(`~/Library/Application Support/smolvm/server/smolvm.db`,
`vms.data` BLOB) between `machine create` and `machine
start` to set the allowlist directly. Smolvm reads the DB
at start, so TSI enforces. Tested end-to-end: VM → `127.0.0.1`
= "Permission denied"; VM → `<alias>:<bundle-port>` =
connects.
Other paths tried that didn't work: `machine update
--allow-cidr` doesn't exist; stop-edit-`agent.config.json`-
restart fails (file removed on stop); `--smolfile` mutually
exclusive with `--from`; `--image localhost:<port>/...` fails
because smolvm's pull agent can't reach host loopback during
pull. When smolvm honors `--allow-cidr` with `--from`
upstream, the DB patch becomes redundant and can be removed.
## References ## References
@@ -0,0 +1,278 @@
"""Unit: per-bottle loopback alias pool (follow-up to the
Docker-Desktop fix in PR #74).
`ensure_pool` lazily sudo-adds missing aliases on macOS; no-ops
on Linux. `allocate` picks the lowest-numbered unused alias by
inspecting running bundle containers' port bindings."""
from __future__ import annotations
import json
import sqlite3
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from claude_bottle.backend.smolmachines import loopback_alias
def _ok(stdout: str = "") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=0, stdout=stdout, stderr="",
)
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
# `ifconfig lo0` on macOS with the default lo0 config: just
# 127.0.0.1. We craft fixtures around this shape.
_LO0_DEFAULT = (
"lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384\n"
"\tinet 127.0.0.1 netmask 0xff000000\n"
"\tinet6 ::1 prefixlen 128\n"
)
_LO0_PARTIAL = (
_LO0_DEFAULT
+ "\tinet 127.0.0.16 netmask 0xffffffff\n"
+ "\tinet 127.0.0.17 netmask 0xffffffff\n"
)
def _lo0_full() -> str:
"""All 16 pool addresses already aliased."""
aliases = "".join(
f"\tinet 127.0.0.{i} netmask 0xffffffff\n"
for i in range(16, 32)
)
return _LO0_DEFAULT + aliases
class TestEnsurePool(unittest.TestCase):
def test_noop_on_linux(self):
# `_is_macos` returns False on Linux; ensure_pool should
# never shell out to sudo.
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias.subprocess, "run") as run:
loopback_alias.ensure_pool()
run.assert_not_called()
def test_all_present_skips_sudo(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias.subprocess, "run",
return_value=_ok(stdout=_lo0_full()),
) as run:
loopback_alias.ensure_pool()
# Just the ifconfig probe per pool address; no sudo at all.
for call in run.call_args_list:
self.assertNotIn("sudo", call.args[0])
def test_missing_aliases_dispatch_sudo(self):
# lo0 only has 16+17 already; sudo runs for 18..31 (14 missing).
runs: list[list[str]] = []
def fake_run(argv, *a, **kw):
runs.append(argv)
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
return _ok(stdout=_LO0_PARTIAL)
return _ok()
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run):
loopback_alias.ensure_pool()
sudo_calls = [r for r in runs if r and r[0] == "sudo"]
self.assertEqual(14, len(sudo_calls))
sudo_ips = {call[call.index("alias") + 1].split("/")[0] for call in sudo_calls}
self.assertEqual(
{f"127.0.0.{i}" for i in range(18, 32)},
sudo_ips,
)
def test_sudo_failure_dies(self):
def fake_run(argv, *a, **kw):
if argv[:2] == ["/sbin/ifconfig", "lo0"]:
return _ok(stdout=_LO0_DEFAULT)
if argv[:1] == ["sudo"]:
return _fail()
return _ok()
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias.subprocess, "run", side_effect=fake_run), \
patch.object(loopback_alias, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
loopback_alias.ensure_pool()
class TestAllocate(unittest.TestCase):
def test_returns_loopback_on_linux(self):
with patch.object(loopback_alias, "_is_macos", return_value=False):
self.assertEqual("127.0.0.1", loopback_alias.allocate("demo"))
def test_picks_lowest_unused_on_macos(self):
# No bundles running -> first pool entry.
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_aliases_in_use", return_value=set()):
self.assertEqual("127.0.0.16", loopback_alias.allocate("demo-1"))
def test_skips_in_use_aliases(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias, "_aliases_in_use",
return_value={"127.0.0.16", "127.0.0.17", "127.0.0.19"},
):
# First unused = 127.0.0.18.
self.assertEqual("127.0.0.18", loopback_alias.allocate("demo-3"))
def test_dies_when_pool_exhausted(self):
all_in_use = {f"127.0.0.{i}" for i in range(16, 32)}
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias, "_aliases_in_use",
return_value=all_in_use,
), patch.object(
loopback_alias, "die", side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
loopback_alias.allocate("demo-overflow")
class TestAliasInUseDetection(unittest.TestCase):
"""`_aliases_in_use` inspects every running bundle and pulls
each container's port-binding `HostIp` out. The detection has
to survive: no running bundles, multiple bundles, docker
inspect failures."""
def test_no_bundles_returns_empty(self):
with patch.object(
loopback_alias.subprocess, "run",
return_value=_ok(stdout=""),
):
self.assertEqual(set(), loopback_alias._aliases_in_use())
def test_walks_bundles_and_pulls_host_ips(self):
# First call: docker ps -> two bundle names.
# Then docker inspect each, returning a port-bindings JSON
# blob with a HostIp on the per-bottle alias.
ps_out = "claude-bottle-sidecars-a\nclaude-bottle-sidecars-b\n"
inspect_a = (
'{"8888/tcp":[{"HostIp":"127.0.0.16","HostPort":"54000"}]}'
)
inspect_b = (
'{"9099/tcp":[{"HostIp":"127.0.0.17","HostPort":"54001"}]}'
)
seq = [
_ok(stdout=ps_out),
_ok(stdout=inspect_a),
_ok(stdout=inspect_b),
]
with patch.object(
loopback_alias.subprocess, "run", side_effect=seq,
):
self.assertEqual(
{"127.0.0.16", "127.0.0.17"},
loopback_alias._aliases_in_use(),
)
def test_inspect_failures_are_skipped(self):
ps_out = "claude-bottle-sidecars-c\n"
with patch.object(
loopback_alias.subprocess, "run",
side_effect=[_ok(stdout=ps_out), _fail("inspect failed")],
):
self.assertEqual(set(), loopback_alias._aliases_in_use())
class TestForceAllowlist(unittest.TestCase):
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`,
so `force_allowlist` opens the state DB directly and sets
the row's `allowed_cidrs` field. Round-trip tests against a
real SQLite DB to lock down the BLOB encoding."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
self.db = Path(self._tmp.name) / "smolvm.db"
con = sqlite3.connect(str(self.db))
con.execute(
"CREATE TABLE vms (name TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL)"
)
# Mimic smolvm's row shape (the JSON keys that exist on
# creation; allowed_cidrs is the field we patch).
cfg = {
"name": "demo-vm",
"cpus": 4,
"mem": 8192,
"network": True,
"allowed_cidrs": None,
}
con.execute(
"INSERT INTO vms (name, data) VALUES (?, ?)",
("demo-vm", sqlite3.Binary(json.dumps(cfg).encode())),
)
con.commit()
con.close()
def tearDown(self):
self._tmp.cleanup()
def test_patches_allowed_cidrs_on_row(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
con = sqlite3.connect(str(self.db))
row = con.execute(
"SELECT typeof(data), data FROM vms WHERE name='demo-vm'",
).fetchone()
con.close()
# Must round-trip as BLOB (the column type smolvm reads).
self.assertEqual("blob", row[0])
cfg = json.loads(row[1])
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
# Other fields preserved verbatim.
self.assertEqual(4, cfg["cpus"])
self.assertTrue(cfg["network"])
def test_noop_on_linux(self):
with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
# DB row should be untouched.
con = sqlite3.connect(str(self.db))
cfg = json.loads(con.execute(
"SELECT data FROM vms WHERE name='demo-vm'",
).fetchone()[0])
con.close()
self.assertIsNone(cfg["allowed_cidrs"])
def test_dies_on_missing_db(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(
loopback_alias, "_SMOLVM_DB_PATH",
Path("/nonexistent/smolvm.db"),
), patch.object(
loopback_alias, "die", side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
def test_dies_on_missing_row(self):
with patch.object(loopback_alias, "_is_macos", return_value=True), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
patch.object(
loopback_alias, "die", side_effect=SystemExit("die"),
):
with self.assertRaises(SystemExit):
loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"])
if __name__ == "__main__":
unittest.main()