feat(smolmachines): per-bottle loopback alias scopes TSI to single /32
test / unit (pull_request) Successful in 27s
test / integration (pull_request) Successful in 41s

PR #74's Docker-Desktop fix routed the agent through
`127.0.0.1:<random>` loopback forwards, but TSI filters by IP
only — so the allowlist `127.0.0.1/32` let the agent VM reach
**any** host service on macOS loopback (postgres, dev servers,
other bottles' published ports, mDNSResponder, ...). Real
downgrade vs the docker backend's `--internal` network.

Resolution: per-bottle loopback alias.

- New `loopback_alias` module manages a pool of
  `127.0.0.16` .. `127.0.0.31` on `lo0`. macOS only routes
  `127.0.0.1` by default; the extras need `sudo ifconfig lo0
  alias`. `ensure_pool()` lazily adds the missing entries via
  one sudo prompt on first launch per reboot — aliases persist
  on `lo0` until reboot, so subsequent launches skip the
  prompt entirely.
- `allocate(slug)` picks the lowest-numbered unused alias by
  inspecting running bundle containers' port-binding HostIps.
  No on-disk reservation — docker is the source of truth.
- Bundle bringup binds published ports to the allocated alias
  (`docker run -p <alias>::<port>`) instead of `127.0.0.1`.
- TSI allowlist becomes the alias's /32 — narrows reachability
  to this bottle's bundle only.
- Linux native daemons share the host's network namespace;
  `127.0.0.0/8` works without aliases, so the module no-ops on
  non-Darwin and returns `127.0.0.1` from `allocate`.

Tracking issue closed: gitea/issues/75.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 16:23:17 -04:00
parent bad195e910
commit 2edc1abb9a
6 changed files with 457 additions and 68 deletions
+8 -13
View File
@@ -200,19 +200,14 @@ 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 needs
Docker Desktop's container IPs aren't reachable from macOS, so the per-bottle loopback aliases (`127.0.0.16` .. `127.0.0.31`) on `lo0`
smolmachines bottle dials the sidecar bundle through host loopback so each bottle's TSI allowlist scopes to its own /32. The first
port-forwards (`127.0.0.1:<random>`). TSI filters by IP only, so the `./cli.py start` after each reboot prompts for sudo to add the pool
allowlist is `127.0.0.1/32` — meaning the agent VM can reach **any via `ifconfig lo0 alias`. Aliases persist until reboot; subsequent
service bound to macOS's loopback**, not just the bundle's published launches don't prompt. Without this, every bottle would share
ports. Practical implication: while a smolmachines bottle is running, `127.0.0.1` and be able to reach unrelated host services on the
host-local dev services (postgres on 5432, dev servers, etc.) are loopback.
reachable from inside the agent even if you intended them to be
host-private. The docker backend keeps the bottle on a `--internal`
docker network and doesn't have this issue. A future revision will
narrow this via a per-bottle loopback alias + host-side proxy (see
PRD 0023's "loopback scoping" section).
## Manifest ## Manifest
+36 -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,21 +151,21 @@ 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
@@ -178,15 +189,16 @@ 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)
@@ -240,7 +252,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 +357,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,176 @@
"""Per-bottle loopback alias allocation (PRD 0023, follow-up to
the Docker-Desktop fix in 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. That's a 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` by default).
The bundle's port-forwards bind to that alias, TSI's allowlist is
the alias /32, and other host loopback services stay invisible to
the bottle.
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.
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 subprocess
from typing import Iterable
from ...log import die, info
# 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 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"]
@@ -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:
+12 -16
View File
@@ -600,22 +600,18 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
the plan is to filter on a deterministic name prefix the plan is to filter on a deterministic name prefix
`claude-bottle-<slug>` + cross-reference with on-disk metadata `claude-bottle-<slug>` + cross-reference with on-disk metadata
under `state/<slug>/`. under `state/<slug>/`.
8. **Loopback scoping (Docker Desktop pivot).** The original 8. **~~Loopback scoping (Docker Desktop pivot).~~ Resolved.**
design pinned the bundle at a docker bridge IP and set TSI's Each bottle now allocates a per-bottle loopback alias from a
allowlist to `<bundle-ip>/32`. On Docker Desktop / macOS the pool of `127.0.0.16` .. `127.0.0.31`, binds the bundle's
daemon runs inside its own Linux VM, so bridge IPs aren't port-forwards to that alias, and sets TSI's allowlist to the
reachable from macOS networking — TSI's syscall impersonation alias's /32. So a smolmachines bottle can only reach its own
can't reach them. Resolution: publish each agent-facing bundle bundle's published ports — not other bottles' ports, and not
port on host loopback (`-p 127.0.0.1::<port>`) and set TSI to unrelated host services on `127.0.0.1`. macOS loopback
`127.0.0.1/32`. **This widens the TSI allowlist to anything aliases need `sudo ifconfig lo0 alias`; the launcher lazily
bound to macOS's loopback** — postgres, dev servers, other adds missing pool entries on first launch per reboot (sudo
bottles' published ports, mDNSResponder, etc. The agent can't prompts once, aliases persist until reboot). Linux native
reach them by intent, but TSI can't filter by port. Follow-up daemons share the host's network namespace and skip the
to scope back: bind each bottle's bundle ports on a per-bottle alias dance.
loopback alias (e.g. `127.0.0.2` for bottle A, `127.0.0.3` for
B) added via `ifconfig lo0 alias`, set TSI to that single /32.
Needs sudo for alias setup; a small daemon-or-script we ship
alongside the launcher could handle it.
## References ## References
@@ -0,0 +1,191 @@
"""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 subprocess
import unittest
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())
if __name__ == "__main__":
unittest.main()