Compare commits

..

1 Commits

Author SHA1 Message Date
didericis-claude 78345b5343 feat(smolmachines): PRD 0022 sandbox-escape suite green under smolmachines (PRD 0023 chunk 5)
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 41s
Final PRD 0023 chunk. The PRD 0022 attack suite was already
backend-agnostic — it goes through get_bottle_backend(), so the
right dispatch happens based on CLAUDE_BOTTLE_BACKEND. Two
cleanups to make it actually run cleanly under
CLAUDE_BOTTLE_BACKEND=smolmachines:

- setUpClass raises unittest.SkipTest with a useful message when
  CLAUDE_BOTTLE_BACKEND=smolmachines but smolvm isn't on PATH, or
  when the host isn't macOS (libkrun + TSI single-IP allowlist is
  macOS-only in v1). Without this, the test would die deep inside
  backend.prepare's smolmachines_preflight rather than skipping.

- test_5_readme_push_blocked switches from a hardcoded
  `git://git-gate/...` remote URL (only resolvable on docker via
  the bundle's short alias) to the bottle's declared upstream URL
  (`ssh://git@unreachable.invalid:22/throwaway.git`). The agent's
  ~/.gitconfig insteadOf rewrite — set up by provision_git on both
  backends — transparently redirects to the gate, so the same test
  exercises docker's `git://git-gate/...` and smolmachines's
  `git://<bundle_ip>:9418/...` URLs without branching on backend.

README gets a "Backend selection" subsection under Quickstart
documenting CLAUDE_BOTTLE_BACKEND, the macOS-only v1 scope for
smolmachines, and the `curl -sSL .../install.sh | sh` install
prerequisite — per PRD 0023's acceptance criteria.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:31:27 -04:00
20 changed files with 248 additions and 865 deletions
+16 -19
View File
@@ -190,30 +190,27 @@ The container is removed automatically when the session ends. If the script
is killed with SIGKILL the exit trap won't fire and the container may be is killed with SIGKILL the exit trap won't fire and the container may be
left running; remove it with `docker rm -f <container-name>`. left running; remove it with `docker rm -f <container-name>`.
### Smolmachines backend (experimental, macOS-only) ### Backend selection
A second backend runs the agent in a smolvm micro-VM (libkrun) with the The default backend uses Docker for both the agent and the sidecar
sidecar bundle still in Docker. Selected via bundle. An experimental smolmachines backend runs the agent in a
`CLAUDE_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>`. Requires [smolvm](https://smolmachines.com) micro-VM (libkrun on macOS) and
`smolvm` on PATH (`curl -sSL https://smolmachines.com/install.sh | sh`). keeps the sidecar bundle in Docker:
```sh
CLAUDE_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>
```
The smolmachines backend is **macOS-only in v1** (libkrun + TSI
single-IP allowlisting) and requires `smolvm` on PATH:
```sh
curl -sSL https://smolmachines.com/install.sh | sh
```
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
Docker Desktop's container IPs aren't reachable from macOS, so the
smolmachines bottle dials the sidecar bundle through host loopback
port-forwards (`127.0.0.1:<random>`). TSI filters by IP only, so the
allowlist is `127.0.0.1/32` — meaning the agent VM can reach **any
service bound to macOS's loopback**, not just the bundle's published
ports. Practical implication: while a smolmachines bottle is running,
host-local dev services (postgres on 5432, dev servers, etc.) are
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
Bottles and agents live as Markdown files with YAML frontmatter under Bottles and agents live as Markdown files with YAML frontmatter under
+6 -14
View File
@@ -119,20 +119,12 @@ class Bottle(ABC):
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ... def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: ...
@abstractmethod @abstractmethod
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str) -> ExecResult:
"""Run `script` as a POSIX shell script inside the bottle as """Run `script` as a POSIX shell script inside the bottle and
`user` (default `node`, matching the agent image's USER return the captured stdout/stderr/returncode. The bottle's
directive) and return the captured stdout/stderr/returncode. environment (including HTTPS_PROXY pointing at the pipelock
The bottle's environment (including HTTPS_PROXY pointing at sidecar) is inherited by the child. Non-zero exit does not
the pipelock sidecar) is inherited by the child. Non-zero raise — callers inspect `returncode` themselves."""
exit does not raise — callers inspect `returncode`
themselves.
Pass `user="root"` for shell-outs that need privileged file
writes / package install — provisioning calls that need root
bypass `Bottle.exec` and use the backend-specific raw
machine-exec helper, but the tests have a legitimate use
case for arbitrary-user runs."""
@abstractmethod @abstractmethod
def cp_in(self, host_path: str, container_path: str) -> None: ... def cp_in(self, host_path: str, container_path: str) -> None: ...
+3 -6
View File
@@ -51,15 +51,12 @@ class DockerBottle(Bottle):
self.claude_docker_argv(argv, tty=tty), check=False, self.claude_docker_argv(argv, tty=tty), check=False,
).returncode ).returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str) -> ExecResult:
# Pipe via stdin to `sh -s` so the caller never has to worry # Pipe via stdin to `sh -s` so the caller never has to worry
# about quoting; the script source lands inside the container # about quoting; the script source lands inside the container
# without crossing argv. `-u <user>` overrides the image's # without crossing argv.
# default USER — defaults to `node` which is already the
# image's USER, so the explicit flag is a no-op there but
# keeps the cross-backend contract uniform.
result = subprocess.run( result = subprocess.run(
["docker", "exec", "-u", user, "-i", self.name, "sh", "-s"], ["docker", "exec", "-i", self.name, "sh", "-s"],
input=script, input=script,
capture_output=True, capture_output=True,
text=True, text=True,
+12 -9
View File
@@ -166,15 +166,18 @@ def image_id(ref: str) -> str:
return r.stdout.strip() return r.stdout.strip()
def save(ref: str, output: str) -> None: def tag(src: str, dst: str) -> None:
"""`docker save REF -o OUTPUT`. Writes a tarball of the image """`docker tag SRC DST`. Idempotent. Used by smolmachines prepare
layers + manifest to the host path. Used by smolmachines to retag the locally-built image into a localhost:<port>/... ref
prepare to hand the agent image to a containerized crane that that the ephemeral registry will accept."""
pushes it to the ephemeral registry — bypassing the docker subprocess.run(["docker", "tag", src, dst], check=True)
daemon's `docker push` (which on Docker Desktop can't reach a
host-loopback registry and refuses plain-HTTP pushes to
non-loopback hosts).""" def push(ref: str) -> None:
subprocess.run(["docker", "save", ref, "-o", output], check=True) """`docker push REF`. Used by smolmachines prepare to push the
agent image into the ephemeral local registry so smolvm's crane
backend can pull it."""
subprocess.run(["docker", "push", ref], check=True)
def _silent_run(cmd: Iterable[str]) -> int: def _silent_run(cmd: Iterable[str]) -> int:
+19 -86
View File
@@ -4,130 +4,63 @@ Routes `exec_claude` / `exec` / `cp_in` through `smolvm machine
exec` / `smolvm machine cp`. The handle is yielded by `launch` exec` / `smolvm machine cp`. The handle is yielded by `launch`
and torn down via the surrounding ExitStack on context exit; and torn down via the surrounding ExitStack on context exit;
`close` is a no-op idempotent alias so the BottleBackend ABC's `close` is a no-op idempotent alias so the BottleBackend ABC's
context-manager contract is satisfied. context-manager contract is satisfied."""
User context: `smolvm machine exec` runs commands as root in the
VM, but the agent image's USER is `node` and claude-code refuses
to run as root with `--dangerously-skip-permissions`. Both
`exec_claude` and `exec` switch to the requested user (default
`node`) via `runuser -u <user> --` and set `HOME` / `USER`
through `smolvm -e` — avoiding `runuser -l`'s login-shell wiring
(PAM session setup, /etc/profile sourcing) which can hang on a
minimal Debian VM with no PAM session config."""
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
from typing import Mapping import sys
from .. import Bottle, ExecResult from .. import Bottle, ExecResult
from . import smolvm as _smolvm from . import smolvm as _smolvm
# Per-user env the agent image's USER (node) expects. claude
# reads ~/.claude.json + writes session state under ~/.claude/;
# bare `runuser -u` inherits root's HOME=/root, which claude
# can't write to. Set HOME / USER explicitly through smolvm -e
# so the child process sees them.
_HOME_FOR = {
"node": "/home/node",
"root": "/root",
}
def _env_flags_for(user: str) -> list[str]:
home = _HOME_FOR.get(user, f"/home/{user}")
return ["-e", f"HOME={home}", "-e", f"USER={user}"]
def _guest_env_flags(env: Mapping[str, str]) -> list[str]:
"""Render `{K: V}` into a flat `-e K=V` argv slice for
`smolvm machine exec`. `smolvm machine create -e` set env
on PID 1 but it doesn't propagate to fresh exec process
trees, so we have to re-pass them every call."""
out: list[str] = []
for k, v in env.items():
out += ["-e", f"{k}={v}"]
return out
class SmolmachinesBottle(Bottle): class SmolmachinesBottle(Bottle):
"""Handle returned by `SmolmachinesBottleBackend.launch`. The """Handle returned by `SmolmachinesBottleBackend.launch`. The
underlying VM lifecycle (create / start / stop / delete) lives underlying VM lifecycle (create / start / stop / delete) lives
on the launch ExitStack — this class only routes runtime on the launch ExitStack — this class only routes runtime
operations to the right `smolvm machine ...` subcommand.""" operations to the right `smolvm machine ...` subcommand."""
def __init__( def __init__(self, machine_name: str, *, prompt_path: str | None = None) -> None:
self,
machine_name: str,
*,
prompt_path: str | None = None,
guest_env: Mapping[str, str] | None = None,
) -> None:
self.name = machine_name self.name = machine_name
# In-VM path to the agent's prompt file. None when the # In-VM path to the agent's prompt file. None when the
# agent declared no prompt (file still exists; we just # agent declared no prompt (file still exists; we just
# don't pass --append-system-prompt-file). # don't pass --append-system-prompt-file).
self._prompt_path = prompt_path self._prompt_path = prompt_path
# Env vars the agent process needs (HTTPS_PROXY,
# CLAUDE_CODE_OAUTH_TOKEN, manifest-declared bottle env, …).
# Forwarded on every `smolvm machine exec` via `-e K=V`
# because exec doesn't inherit from machine_create's env.
self._guest_env = dict(guest_env or {})
def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: def exec_claude(self, argv: list[str], *, tty: bool = True) -> int:
"""Run `claude` interactively inside the VM as the `node` """Run `claude` interactively inside the VM. Inherits the
user. Inherits the operator's terminal (stdin / stdout / operator's terminal (stdin / stdout / stderr) so the
stderr) so the session feels native. Blocks until claude session feels native. Blocks until claude exits; returns
exits; returns the in-VM exit code. the in-VM exit code.
We bypass the captured-output `machine_exec` helper here We bypass the captured-output `machine_exec` helper here
because that one wraps stdout/stderr in pipes — fine for because that one wraps stdout/stderr in pipes — fine for
scripted exec, wrong for an interactive shell. Drop down scripted exec, wrong for an interactive shell. Drop down
to `subprocess.run` with the TTY inherited. to `subprocess.run` with the TTY inherited."""
UID switches via `runuser -u node --` (not `-l`) so we
avoid login-shell wiring. HOME / USER come from `smolvm
-e` instead, which sets them on the process env."""
flags = ["smolvm", "machine", "exec", "--name", self.name] flags = ["smolvm", "machine", "exec", "--name", self.name]
if tty: if tty:
flags += ["-i", "-t"] flags += ["-i", "-t"]
flags += _env_flags_for("node")
flags += _guest_env_flags(self._guest_env)
claude_argv = ["claude"] claude_argv = ["claude"]
if self._prompt_path: if self._prompt_path:
claude_argv += ["--append-system-prompt-file", self._prompt_path] claude_argv += ["--append-system-prompt-file", self._prompt_path]
claude_argv += argv flags += ["--", *claude_argv, *argv]
flags += ["--", "runuser", "-u", "node", "--", *claude_argv]
result = subprocess.run(flags, check=False) result = subprocess.run(flags, check=False)
return result.returncode return result.returncode
def exec(self, script: str, *, user: str = "node") -> ExecResult: def exec(self, script: str) -> ExecResult:
"""Run a POSIX shell script as `user` (default `node`) and """Run a POSIX shell script and capture the result. The
capture the result. Matches the docker backend's `exec`, script runs under `/bin/sh -c`, matching what the docker
which defaults to the image's USER (also node) — so test backend's `exec` does — callers can write shell-y test
helpers / provision shell-outs run with the same identity helpers without worrying about argv splitting."""
on both backends. Pass `user="root"` for tests that need r = _smolvm.machine_exec(
root. self.name,
["/bin/sh", "-c", script],
`runuser -u <user> -- /bin/sh -c <script>` switches UID
without invoking a login shell; HOME / USER are set via
`smolvm -e` (see `_env_flags_for`)."""
argv = (
_env_flags_for(user)
+ _guest_env_flags(self._guest_env)
+ ["--", "runuser", "-u", user, "--", "/bin/sh", "-c", script]
)
# _smolvm.machine_exec expects argv (the bit after `--`);
# the -e flags go before, so call smolvm directly.
r = subprocess.run(
["smolvm", "machine", "exec", "--name", self.name] + argv,
capture_output=True, text=True, check=False,
) )
return ExecResult( return ExecResult(
returncode=r.returncode, returncode=r.returncode,
stdout=r.stdout or "", stdout=r.stdout,
stderr=r.stderr or "", stderr=r.stderr,
) )
def cp_in(self, host_path: str, container_path: str) -> None: def cp_in(self, host_path: str, container_path: str) -> None:
@@ -75,17 +75,6 @@ class SmolmachinesBottlePlan(BottlePlan):
# None when bottle.supervise is False, matching the docker # None when bottle.supervise is False, matching the docker
# backend's convention. # backend's convention.
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
# Agent-side endpoints. On Docker Desktop the docker bridge
# IPs aren't reachable from the smolvm guest (TSI uses macOS
# networking; docker container IPs live in the daemon's VM),
# so the agent dials the bundle via host loopback +
# docker-published random ports. Empty at prepare time;
# launch populates these after bundle bringup via
# `dataclasses.replace`. Format: a `host:port` for git-gate
# (insteadOf URL prefix) + full URLs for proxy / supervise.
agent_proxy_url: str = ""
agent_git_gate_host: str = ""
agent_supervise_url: str = ""
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Compact y/N preflight. Same shape as the Docker """Compact y/N preflight. Same shape as the Docker
@@ -100,10 +89,7 @@ class SmolmachinesBottlePlan(BottlePlan):
upstreams = [ upstreams = [
f"{g.Name}{g.Upstream}" for g in bottle.git f"{g.Name}{g.Upstream}" for g in bottle.git
] ]
# Use the resolved egress_plan (lowercase `host` on the routes = [r.host for r in bottle.egress.routes]
# plan-level EgressRoute) rather than `bottle.egress.routes`,
# which is the manifest's capitalized-attr form.
routes = [r.host for r in self.egress_plan.routes]
print(file=sys.stderr) print(file=sys.stderr)
info(f"agent : {spec.agent_name}") info(f"agent : {spec.agent_name}")
+12 -147
View File
@@ -21,7 +21,6 @@ from __future__ import annotations
import dataclasses import dataclasses
import os import os
import time
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from typing import Callable, Generator from typing import Callable, Generator
@@ -35,7 +34,6 @@ from ...util import expand_tilde
from ..docker.egress import ( from ..docker.egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER, EGRESS_PIPELOCK_CA_IN_CONTAINER,
EGRESS_PORT as _EGRESS_PORT,
egress_tls_init, egress_tls_init,
) )
from ..docker.git_gate import ( from ..docker.git_gate import (
@@ -43,28 +41,14 @@ from ..docker.git_gate import (
GIT_GATE_CREDS_DIR_IN_CONTAINER, GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER,
GIT_GATE_PORT as _GIT_GATE_PORT,
)
from ..docker.pipelock import (
BUNDLE_LOCAL_PIPELOCK_URL,
PIPELOCK_PORT as _PIPELOCK_PORT_STR,
pipelock_tls_init,
) )
from ..docker.pipelock import BUNDLE_LOCAL_PIPELOCK_URL, pipelock_tls_init
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
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
# Container-internal listening ports for each bundle daemon. The
# bundle publishes each one on a random host loopback port (see
# `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks
# them up post-start. Pipelock's port is an env-overridable string
# in docker.pipelock; coerce to int here.
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
_SUPERVISE_PORT = SUPERVISE_PORT
@contextmanager @contextmanager
def launch( def launch(
plan: SmolmachinesBottlePlan, plan: SmolmachinesBottlePlan,
@@ -112,129 +96,31 @@ 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.
# ports_to_publish list expands depending on which daemons
# the agent needs to reach from the smolvm guest.
bundle_spec = _bundle_launch_spec(plan, network) bundle_spec = _bundle_launch_spec(plan, network)
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. smolvm VM. --from carries the pre-packed .smolmachine
# bundle's published container ports, and bind the
# agent's URLs to `127.0.0.1:<host port>`. Docker container
# IPs (192.168.x.x in the daemon's bridge) aren't
# reachable from the smolvm guest on macOS — TSI uses
# macOS networking, and macOS sees the daemon's bridge
# via the published-port loopback forward only.
#
# Proxy hop order matches the docker backend: when the
# bottle declares egress routes, the agent's first hop is
# egress (for token injection), then pipelock. Without
# routes, the agent dials pipelock directly. Whichever
# one is "agent-facing" is the daemon whose port we
# publish on host loopback; the other stays bundle-
# internal as the upstream proxy.
if plan.egress_plan.routes:
agent_facing_port = _EGRESS_PORT
else:
agent_facing_port = _PIPELOCK_PORT
agent_facing_host_port = _bundle.bundle_host_port(
plan.slug, agent_facing_port,
)
agent_proxy_url = f"http://127.0.0.1:{agent_facing_host_port}"
agent_git_gate_host = ""
if plan.git_gate_plan.upstreams:
git_gate_host_port = _bundle.bundle_host_port(
plan.slug, _GIT_GATE_PORT,
)
agent_git_gate_host = f"127.0.0.1:{git_gate_host_port}"
agent_supervise_url = ""
if plan.supervise_plan is not None:
supervise_host_port = _bundle.bundle_host_port(
plan.slug, _SUPERVISE_PORT,
)
agent_supervise_url = f"http://127.0.0.1:{supervise_host_port}/"
# Stamp the URLs onto the plan + guest_env. provision_git
# and provision_supervise read the plan fields; the agent
# reads guest_env on every exec_claude.
guest_env = {
**plan.guest_env,
"HTTPS_PROXY": agent_proxy_url,
"HTTP_PROXY": agent_proxy_url,
}
if agent_git_gate_host:
guest_env["GIT_GATE_URL"] = f"git://{agent_git_gate_host}"
if agent_supervise_url:
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
plan = dataclasses.replace(
plan,
guest_env=guest_env,
agent_proxy_url=agent_proxy_url,
agent_git_gate_host=agent_git_gate_host,
agent_supervise_url=agent_supervise_url,
)
# 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. Smolfile isn't usable
# `127.0.0.1/32` because every bundle daemon the agent # here — smolvm 0.8.0 makes `--from` and `--smolfile`
# reaches is fronted by a host loopback port-forward. # mutually exclusive.
# Smolfile isn't usable 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"{plan.bundle_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)
_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)
# 6. Repair filesystem ownership + perms that smolvm's # 5. Provision (CA / prompt / skills / git / supervise).
# pack process remapped to the host invoker's uid (501
# on macOS) rather than preserving the image's expected
# ownership.
#
# - /home/node → node:node so the node user can write
# its own dotfiles (claude appendFileSync on
# ~/.claude.json otherwise bails with ENOENT/EPERM
# and the TUI hangs without surfacing the error).
# - /tmp + /var/tmp → root:root mode 1777 so non-root
# processes can create their per-uid scratch dirs
# (claude-code creates /tmp/claude-<uid>/ as soon as
# it spawns a Bash tool call).
#
# All folded into one sh -c so we only pay one
# machine_exec round trip — back-to-back exec calls
# right after machine_start hit a SIGKILL race in
# libkrun's exec channel (see provision_ca for the
# other half of this same workaround).
_smolvm.machine_exec(plan.machine_name, [
"sh", "-c",
"chown -R node:node /home/node && "
"chown root:root /tmp /var/tmp && "
"chmod 1777 /tmp /var/tmp",
])
# Wait briefly for the VM to settle. Back-to-back smolvm
# machine_exec calls immediately after machine_start
# occasionally SIGKILL the in-VM child at ~100ms (looks
# like a VM warm-up race in libkrun's exec channel).
# 1.5s is empirically enough to dodge it; provisioning
# already takes seconds so the wait is amortized.
time.sleep(1.5)
# 7. Provision (CA / prompt / skills / git / supervise).
prompt_path = provision(plan, plan.machine_name) prompt_path = provision(plan, plan.machine_name)
yield SmolmachinesBottle( yield SmolmachinesBottle(plan.machine_name, prompt_path=prompt_path)
plan.machine_name,
prompt_path=prompt_path,
guest_env=plan.guest_env,
)
finally: finally:
stack.close() stack.close()
@@ -258,14 +144,9 @@ def _bundle_launch_spec(
env: list[str] = [] env: list[str] = []
volumes: list[tuple[str, str, bool]] = [] volumes: list[tuple[str, str, bool]] = []
# In this Docker-Desktop-compatible topology, whichever daemon # PRD 0023 chunk 3: egress binds 127.0.0.1 inside the bundle
# is "agent-facing" gets its port published on the host # so TSI's IP-only allowlist can't bypass pipelock.
# loopback (see `_ensure_smolmachine`'s discovery loop) and the env.append("EGRESS_LISTEN_HOST=127.0.0.1")
# other stays bundle-internal. The bundle is NOT reachable by
# bridge IP from the smolvm guest, so the
# PRD-0023-chunk-3 EGRESS_LISTEN_HOST=127.0.0.1 mitigation
# isn't needed: the agent can only dial whatever daemon's
# host port we publish, period.
# --- pipelock --------------------------------------------- # --- pipelock ---------------------------------------------
pp = plan.proxy_plan pp = plan.proxy_plan
@@ -320,21 +201,6 @@ def _bundle_launch_spec(
] ]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False)) volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI +
# macOS networking. The HTTP/HTTPS chokepoint is whichever
# daemon's port we publish: egress when routes are declared
# (token injection first, then forwards to bundle-internal
# pipelock), pipelock otherwise.
if ep.routes:
ports_to_publish: list[int] = [_EGRESS_PORT]
else:
ports_to_publish = [_PIPELOCK_PORT]
if gp.upstreams:
ports_to_publish.append(_GIT_GATE_PORT)
if sp is not None:
ports_to_publish.append(_SUPERVISE_PORT)
return _bundle.BundleLaunchSpec( return _bundle.BundleLaunchSpec(
slug=plan.slug, slug=plan.slug,
network_name=network, network_name=network,
@@ -344,7 +210,6 @@ def _bundle_launch_spec(
daemons_csv=",".join(daemons), daemons_csv=",".join(daemons),
environment=tuple(env), environment=tuple(env),
volumes=tuple(volumes), volumes=tuple(volumes),
ports_to_publish=tuple(ports_to_publish),
) )
@@ -1,37 +1,17 @@
"""Ephemeral local OCI registry for the smolmachines agent-image """Ephemeral local OCI registry for the smolmachines agent-image
conversion path (PRD 0023 chunk 4c). conversion path (PRD 0023 chunk 4c).
`smolvm pack create --image <ref>` only accepts OCI registry refs `smolvm pack create --image <ref>` only accepts registry refs — it
— it can't read the local docker daemon's image cache, an OCI can't read the local docker daemon's image cache, an OCI layout
layout directory, or a `docker save` tarball. To convert the directory, or a `docker save` tarball. To convert the agent's
agent's Dockerfile-built image into a `.smolmachine` artifact we Dockerfile-built image into a `.smolmachine` artifact we run a
spin up a short-lived `registry:2.8.3` container alongside a short-lived `registry:2.8.3` container on `127.0.0.1:<random>`,
`crane` helper container on a private docker network, push via push the locally-tagged image into it, and let smolvm pull from
`crane push --insecure <tarball> <registry-container>:5000/...`, there. The registry container is torn down as soon as the pack
and let smolvm pull from the registry's published host port. The completes.
network + both containers are torn down after the pack completes.
Why this two-container dance instead of plain `docker push`: Loopback-only bind + the host's docker layer cache mean the round
- Docker Desktop's daemon runs in its own Linux VM, so its trip is fast (~5s) and there's no exposed surface on the LAN."""
`localhost` is not the host's loopback. A registry bound to
the host's 127.0.0.1 is unreachable from the daemon side.
- `host.docker.internal` is reachable from the daemon but isn't
in Docker's default insecure-registries CIDRs (only `::1/128`
and `127.0.0.0/8` are), so `docker push` to it tries HTTPS,
hits a plain-HTTP registry, and dies with
`http: server gave HTTP response to HTTPS client`. Adding
`host.docker.internal` to daemon.json works but is a one-time
manual step the user has to do in Docker Desktop's UI.
- Going through a docker network sidesteps the host-vs-daemon
loopback mismatch (crane and registry containers see each
other on the network) AND the HTTPS preference (crane has an
`--insecure` flag that forces plain HTTP).
The registry is also published on a random host port so smolvm
— a host process — can pull from `localhost:<port>` via Docker's
port-forward. smolvm's bundled crane auto-falls-back to HTTP for
localhost addresses, so no insecure-registries config is needed
on that side either."""
from __future__ import annotations from __future__ import annotations
@@ -41,7 +21,6 @@ import subprocess
import time import time
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass
from typing import Iterator from typing import Iterator
from ...log import die from ...log import die
@@ -55,150 +34,64 @@ REGISTRY_IMAGE = os.environ.get(
) )
# gcr.io/go-containerregistry/crane:latest, pinned by digest. ~10MB,
# stable upstream from Google; we only invoke `crane push --insecure`
# against a localhost-equivalent registry, so the trust surface is
# narrow.
CRANE_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_CRANE_IMAGE",
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
)
# Internal port the registry binds to inside its container — fixed
# by the registry:2 image. The host-side mapping is random.
_REGISTRY_CONTAINER_PORT = "5000"
# How long to wait for the registry's HTTP layer to bind before # How long to wait for the registry's HTTP layer to bind before
# giving up. Two seconds is empirically enough; 10s leaves headroom # giving up. Two seconds is empirically enough; bumping to 10s leaves
# for slow CI runners without making the failure mode chatty. # headroom for slow CI runners without making the failure mode chatty.
_READY_TIMEOUT_S = 10.0 _READY_TIMEOUT_S = 10.0
@dataclass(frozen=True)
class RegistryHandle:
"""Everything callers need to push to + pull from the ephemeral
registry.
`network` is the per-session docker network — a `crane push`
container has to join it to reach the registry by name.
`push_endpoint` is the `<host>:<port>` form to embed in image
refs given to the crane push container (resolves via docker
network DNS). `pull_endpoint` is the `<host>:<port>` form a
host process (smolvm) uses; the registry's host port mapping
backs this."""
network: str
push_endpoint: str
pull_endpoint: str
@contextmanager @contextmanager
def ephemeral_registry() -> Iterator[RegistryHandle]: def ephemeral_registry() -> Iterator[int]:
"""Bring up a per-session docker network + a `registry:2.8.3` """Bring up a `registry:2.8.3` container on a random loopback
container on it (published on a random host port), yield a port, yield the port, force-remove the container on exit.
`RegistryHandle`, force-remove both on exit.
The container is started with `--rm` so a clean exit cleans up The container is started with `--rm` so a clean exit cleans up
on its own; the `finally` block force-removes on abnormal exit on its own; the `finally` block force-removes on abnormal exit
(the calling process crashes between yield and close).""" (the calling process crashes between yield and close)."""
session_id = uuid.uuid4().hex[:12] name = f"claude-bottle-registry-{uuid.uuid4().hex[:12]}"
network = f"claude-bottle-registry-net-{session_id}"
registry_name = f"claude-bottle-registry-{session_id}"
subprocess.run( subprocess.run(
["docker", "network", "create", network], [
"docker", "run", "-d", "--rm",
"--name", name,
# `127.0.0.1::5000` = bind to loopback, pick a random host
# port. No LAN exposure; the container hangs around just
# long enough for one push + one pack-create.
"-p", "127.0.0.1::5000",
REGISTRY_IMAGE,
],
check=True, check=True,
capture_output=True, capture_output=True,
) )
try: try:
subprocess.run( port = _host_port(name)
[ _wait_ready(port)
"docker", "run", "-d", "--rm", yield port
"--name", registry_name,
"--network", network,
# `-p :5000` (no IP prefix) binds the container's
# port 5000 on a random host port across all
# interfaces. The host side reaches the registry
# via this port — smolvm's `pack create` pulls from
# `localhost:<port>` and the docker port-forward
# routes there.
"-p", _REGISTRY_CONTAINER_PORT,
REGISTRY_IMAGE,
],
check=True,
capture_output=True,
)
try:
port = _host_port(registry_name)
_wait_ready(port)
yield RegistryHandle(
network=network,
push_endpoint=f"{registry_name}:{_REGISTRY_CONTAINER_PORT}",
pull_endpoint=f"localhost:{port}",
)
finally:
subprocess.run(
["docker", "rm", "-f", registry_name],
check=False,
capture_output=True,
)
finally: finally:
subprocess.run( subprocess.run(
["docker", "network", "rm", network], ["docker", "rm", "-f", name],
check=False, check=False,
capture_output=True, capture_output=True,
) )
def crane_push_tarball(handle: RegistryHandle, tarball_path: str, ref: str) -> None:
"""Run `crane push --insecure <tarball> <ref>` inside a one-shot
container on the registry's docker network. `ref` should
reference the registry by `handle.push_endpoint` so the crane
container resolves it via docker network DNS.
Doesn't go through `docker push` to avoid the Docker-Desktop
daemon's HTTPS preference for non-loopback hostnames — crane's
`--insecure` flag forces plain HTTP, which is what the
registry container speaks."""
r = subprocess.run(
[
"docker", "run", "--rm",
"--network", handle.network,
"-v", f"{tarball_path}:/img.tar:ro",
CRANE_IMAGE,
"push", "--insecure", "/img.tar", ref,
],
capture_output=True,
text=True,
check=False,
)
if r.returncode != 0:
die(
f"crane push of {tarball_path!r} to {ref!r} failed: "
f"{(r.stderr or r.stdout or '').strip() or '<no output>'}"
)
def _host_port(name: str) -> int: def _host_port(name: str) -> int:
"""Resolve the host-side port docker mapped to the registry's """Resolve the host-side port docker mapped to the registry's
container port. `docker port <name> 5000/tcp` returns one or container port 5000. `docker port <name> 5000/tcp` returns one or
more `host:port` lines (one per address family) — we take the more `host:port` lines; the loopback-only -p binding ensures we
first.""" get exactly `127.0.0.1:<port>`."""
r = subprocess.run( r = subprocess.run(
["docker", "port", name, f"{_REGISTRY_CONTAINER_PORT}/tcp"], ["docker", "port", name, "5000/tcp"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
) )
if r.returncode != 0: if r.returncode != 0:
die( die(
f"docker port {name} {_REGISTRY_CONTAINER_PORT}/tcp failed: " f"docker port {name} 5000/tcp failed: "
f"{(r.stderr or '').strip() or '<no stderr>'}" f"{(r.stderr or '').strip() or '<no stderr>'}"
) )
# `0.0.0.0:54321\n[::]:54321\n` — split on the last colon to # `127.0.0.1:54321\n` — split on the last colon to handle the
# handle either IPv4 or IPv6 host syntax. # `host:port` shape without parsing IP literals.
line = (r.stdout or "").splitlines()[0].strip() line = (r.stdout or "").splitlines()[0].strip()
_, _, port_str = line.rpartition(":") _, _, port_str = line.rpartition(":")
try: try:
@@ -209,15 +102,12 @@ def _host_port(name: str) -> int:
def _wait_ready(port: int) -> None: def _wait_ready(port: int) -> None:
"""Block until the registry's HTTP layer accepts a TCP """Block until the registry's HTTP layer accepts a TCP connection
connection on `127.0.0.1:<port>`, or `_READY_TIMEOUT_S` on `127.0.0.1:<port>`, or `_READY_TIMEOUT_S` elapses.
elapses.
A successful TCP connect is sufficient — registry:2.8.3 binds A successful TCP connect is sufficient — registry:2.8.3 binds
after it's ready to serve `/v2/` requests, so the push that after it's ready to serve `/v2/` requests, so the push that
follows will land on a working server. We probe loopback follows will land on a working server."""
specifically (not via the docker network) because this helper
runs on the host."""
deadline = time.monotonic() + _READY_TIMEOUT_S deadline = time.monotonic() + _READY_TIMEOUT_S
last_err: Exception | None = None last_err: Exception | None = None
while time.monotonic() < deadline: while time.monotonic() < deadline:
+26 -53
View File
@@ -34,7 +34,7 @@ from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from . import smolvm as _smolvm from . import smolvm as _smolvm
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .local_registry import crane_push_tarball, ephemeral_registry from .local_registry import ephemeral_registry
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
@@ -89,23 +89,22 @@ def resolve_plan(
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
# Agent's env: the prepare-time view doesn't yet know the # Agent's env. IP literals; no DNS resolution inside the guest
# host loopback ports the bundle's daemons get published on # (TSI allowlist contains only `<bundle_ip>/32` — no resolver).
# (those come from docker AFTER `docker run` returns), so
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are
# populated in launch.py and stamped onto guest_env there.
# What we set here is the part that doesn't depend on
# bundle bringup — bottle.env literals, the empty-NO_PROXY
# safe default, and the TLS trust env trio
# (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE)
# pointing at Debian's update-ca-certificates output bundle.
guest_env: dict[str, str] = { guest_env: dict[str, str] = {
**bottle.env, **bottle.env,
"HTTPS_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}",
"HTTP_PROXY": f"http://{bundle_ip}:{_BUNDLE_PIPELOCK_PORT}",
"NO_PROXY": "localhost,127.0.0.1", "NO_PROXY": "localhost,127.0.0.1",
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
} }
if bottle.git:
guest_env["GIT_GATE_URL"] = (
f"git://{bundle_ip}:{_BUNDLE_GIT_GATE_PORT}"
)
if bottle.supervise:
guest_env["MCP_SUPERVISE_URL"] = (
f"http://{bundle_ip}:{_BUNDLE_SUPERVISE_PORT}"
)
# Inner Plans for the four bundle daemons. The ABCs are # Inner Plans for the four bundle daemons. The ABCs are
# platform-neutral — `.prepare()` writes config files + returns # platform-neutral — `.prepare()` writes config files + returns
@@ -125,19 +124,6 @@ def resolve_plan(
egress_dir.mkdir(parents=True, exist_ok=True) egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = Egress().prepare(bottle, slug, egress_dir) egress_plan = Egress().prepare(bottle, slug, egress_dir)
# Claude-code refuses to start without *something* it
# recognises as a credential. When the bottle has an egress
# route carrying the `claude_code_oauth` role marker, egress
# strips + re-injects the real Authorization header on the
# outbound leg using a token held in egress's own environ — so
# the agent gets a non-secret placeholder here (matches the
# docker backend's forwarded_env logic in
# claude_bottle/backend/docker/prepare.py).
if any("claude_code_oauth" in r.roles for r in egress_plan.routes):
guest_env["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder"
guest_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
guest_env.setdefault("DISABLE_ERROR_REPORTING", "1")
supervise_plan = None supervise_plan = None
if bottle.supervise: if bottle.supervise:
supervise_dir = supervise_state_dir(slug) supervise_dir = supervise_state_dir(slug)
@@ -198,19 +184,14 @@ def _ensure_smolmachine(image_ref: str) -> Path:
a launcher binary at `.smolmachine` plus the sidecar alongside a launcher binary at `.smolmachine` plus the sidecar alongside
it; the sidecar is the actual artifact). it; the sidecar is the actual artifact).
Conversion path: `docker build` (the existing layer cache Conversion path: `docker build` (the existing layer cache makes
makes no-change rebuilds cheap) `docker save` to a tarball no-change rebuilds cheap) `docker tag` with a
spin up an ephemeral registry on a private docker network `localhost:<port>/...` ref bring up the ephemeral registry
`crane push --insecure` from a one-shot container on the same container `docker push` into it `smolvm pack create --image
network `smolvm pack create --image localhost:<host port>/...` <localhost ref>` tear down the registry. Each pack-create
tear down the registry + network. The crane push detour costs several seconds even on a hot cache, so we skip the whole
sidesteps the Docker-Desktop daemon's HTTPS preference for pipeline when the cached sidecar is already on disk for this
non-loopback registries see the `local_registry` module image ID."""
docstring for the gory details.
Each pack-create costs several seconds even on a hot cache,
so we skip the whole pipeline when the cached sidecar is
already on disk for this image ID."""
_SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True) _SMOLMACHINE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
docker_mod.build_image(image_ref, _REPO_DIR) docker_mod.build_image(image_ref, _REPO_DIR)
# `sha256:abcd...` -> `abcd...` first 16 chars: short enough to # `sha256:abcd...` -> `abcd...` first 16 chars: short enough to
@@ -221,17 +202,9 @@ def _ensure_smolmachine(image_ref: str) -> Path:
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine" sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
if sidecar.is_file(): if sidecar.is_file():
return sidecar return sidecar
tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar" with ephemeral_registry() as port:
docker_mod.save(image_ref, str(tarball)) local_ref = f"localhost:{port}/claude-bottle:{digest}"
try: docker_mod.tag(image_ref, local_ref)
with ephemeral_registry() as handle: docker_mod.push(local_ref)
push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}" _smolvm.pack_create(local_ref, binary)
pack_ref = f"{handle.pull_endpoint}/claude-bottle:{digest}"
crane_push_tarball(handle, str(tarball), push_ref)
_smolvm.pack_create(pack_ref, binary)
finally:
# Tarball is ~500MB-1GB for the agent image; reclaim once
# the smolmachine artifact exists. The artifact itself is
# the long-lived cache entry.
tarball.unlink(missing_ok=True)
return sidecar return sidecar
@@ -66,29 +66,8 @@ def provision_ca(plan: SmolmachinesBottlePlan, target: str) -> None:
# default. The env trio (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / # default. The env trio (NODE_EXTRA_CA_CERTS / SSL_CERT_FILE /
# REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python # REQUESTS_CA_BUNDLE) on the guest_env covers Node + Python
# `requests` / libraries that don't load the system bundle. # `requests` / libraries that don't load the system bundle.
# _smolvm.machine_exec(target, ["chmod", "644", AGENT_CA_PATH])
# chown + chmod + update-ca-certificates run in one _smolvm.machine_exec(target, ["update-ca-certificates"])
# `sh -c` so we only pay one machine_exec round trip; the
# `&&` chaining surfaces the first failure as the return
# code.
r = _smolvm.machine_exec(target, [
"sh", "-c",
f"chown root:root {AGENT_CA_PATH} && "
f"chmod 644 {AGENT_CA_PATH} && "
f"update-ca-certificates",
])
if r.returncode != 0 or "1 added" not in (r.stdout or ""):
# update-ca-certificates not adding our cert is fatal —
# claude-code's TLS handshake against the egress-MITM'd
# api.anthropic.com would fail downstream. Bail early
# with what we can see (output is captured by smolvm so
# we can surface it).
die(
f"update-ca-certificates didn't add the agent CA "
f"(exit {r.returncode}): "
f"stdout={(r.stdout or '').strip()!r} "
f"stderr={(r.stderr or '').strip()!r}"
)
# Stdlib SHA-256 of the cert's DER bytes — the standard # Stdlib SHA-256 of the cert's DER bytes — the standard
# fingerprint form. Never the private key. # fingerprint form. Never the private key.
@@ -28,6 +28,7 @@ from pathlib import Path
from ....git_gate import git_gate_render_gitconfig from ....git_gate import git_gate_render_gitconfig
from ....log import info from ....log import info
from ...docker.git_gate import GIT_GATE_PORT
from .. import smolvm as _smolvm from .. import smolvm as _smolvm
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
@@ -78,12 +79,10 @@ def _provision_git_gate_config(plan: SmolmachinesBottlePlan, target: str) -> Non
if not bottle.git: if not bottle.git:
return return
# `127.0.0.1:<host port>` form: the bundle's git-gate port # IP-literal form: the TSI allowlist passes <bundle_ip>/32 and
# is published on host loopback at launch time so the # nothing else, so the agent has to dial the gate by IP+port.
# smolvm guest (which can only reach macOS networking via gate_host = f"{plan.bundle_ip}:{GIT_GATE_PORT}"
# TSI, not the docker bridge IP) can dial it. launch.py content = git_gate_render_gitconfig(bottle.git, gate_host)
# populates `plan.agent_git_gate_host` after bundle bringup.
content = git_gate_render_gitconfig(bottle.git, plan.agent_git_gate_host)
guest_gitconfig = f"{_guest_home()}/.gitconfig" guest_gitconfig = f"{_guest_home()}/.gitconfig"
# Stage the file under the plan's stage_dir so `machine cp` # Stage the file under the plan's stage_dir so `machine cp`
@@ -14,6 +14,7 @@ short `supervise` alias (no DNS in the TSI-allowlisted guest)."""
from __future__ import annotations from __future__ import annotations
from ....log import info, warn from ....log import info, warn
from ....supervise import SUPERVISE_PORT
from .. import smolvm as _smolvm from .. import smolvm as _smolvm
from ..bottle_plan import SmolmachinesBottlePlan from ..bottle_plan import SmolmachinesBottlePlan
@@ -21,33 +22,25 @@ from ..bottle_plan import SmolmachinesBottlePlan
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
def supervise_mcp_url(bundle_ip: str) -> str:
return f"http://{bundle_ip}:{SUPERVISE_PORT}/"
def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None: def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
"""Run `claude mcp add` inside the guest to register the """Run `claude mcp add` inside the guest to register the
supervise sidecar in claude-code's user config. No-op when supervise sidecar in claude-code's user config. No-op when
bottle.supervise is False. bottle.supervise is False.
The URL is the agent-side endpoint launch.py populated after
bundle bringup `http://127.0.0.1:<host port>/` rather than
the bundle's docker bridge IP, because that bridge isn't
reachable from the smolvm guest on macOS.
Failure is logged but not fatal: the bottle still works (you Failure is logged but not fatal: the bottle still works (you
just can't call supervise tools from the agent until the entry just can't call supervise tools from the agent until the entry
is added manually). The operator sees the warning at launch.""" is added manually). The operator sees the warning at launch."""
if plan.supervise_plan is None: if plan.supervise_plan is None:
return return
url = plan.agent_supervise_url url = supervise_mcp_url(plan.bundle_ip)
info(f"registering supervise MCP server in agent claude config → {url}") info(f"registering supervise MCP server in agent claude config → {url}")
# `claude mcp add --scope user` writes to ~/.claude.json. The
# agent is the `node` user; smolvm machine_exec runs as root
# by default, so we have to switch user explicitly and set
# HOME so the config lands in /home/node/.claude.json (where
# the agent's claude actually reads it from).
r = _smolvm.machine_exec( r = _smolvm.machine_exec(
target, target,
[ [
"runuser", "-u", "node", "--",
"env", "HOME=/home/node",
"claude", "mcp", "add", "claude", "mcp", "add",
"--scope", "user", "--scope", "user",
"--transport", "http", "--transport", "http",
@@ -64,4 +57,4 @@ def provision_supervise(plan: SmolmachinesBottlePlan, target: str) -> None:
) )
__all__ = ["provision_supervise"] __all__ = ["provision_supervise", "supervise_mcp_url"]
@@ -70,13 +70,6 @@ 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
# host-side port per entry. The smolvm guest's TSI talks via
# macOS networking, so docker container IPs (192.168.x.x in
# the daemon's bridge) aren't directly reachable from the
# guest — host-loopback port-forwards are. Egress's port
# is bundle-internal and never published.
ports_to_publish: Sequence[int] = field(default_factory=tuple)
def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None: def create_bundle_network(network_name: str, subnet: str, gateway: str) -> None:
@@ -142,11 +135,6 @@ def start_bundle(spec: BundleLaunchSpec, *,
for host_path, container_path, read_only in spec.volumes: for host_path, container_path, read_only in spec.volumes:
suffix = ":ro" if read_only else "" suffix = ":ro" if read_only else ""
argv += ["-v", f"{host_path}:{container_path}{suffix}"] argv += ["-v", f"{host_path}:{container_path}{suffix}"]
# Loopback-only host port-forwards — the smolvm guest's TSI
# uses macOS networking, and macOS loopback is the only host
# surface that round-trips into Docker Desktop's daemon VM.
for port in spec.ports_to_publish:
argv += ["-p", f"127.0.0.1::{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,33 +147,6 @@ def start_bundle(spec: BundleLaunchSpec, *,
) )
def bundle_host_port(slug: str, container_port: int) -> int:
"""`docker port <bundle> <container_port>/tcp` → the random
host-side port docker assigned. Called after `start_bundle`
on each container port listed in `BundleLaunchSpec
.ports_to_publish` so the launch step can build the agent's
HTTPS_PROXY / GIT_GATE / SUPERVISE URLs in
`127.0.0.1:<host port>` form."""
container = bundle_container_name(slug)
result = subprocess.run(
["docker", "port", container, f"{container_port}/tcp"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(
f"docker port {container} {container_port}/tcp failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
# `127.0.0.1:54321\n` — rpartition on last colon gives the port.
line = (result.stdout or "").splitlines()[0].strip()
_, _, port_str = line.rpartition(":")
try:
return int(port_str)
except ValueError:
die(f"unexpected `docker port` output: {line!r}")
return -1 # unreachable; die() never returns
def stop_bundle(slug: str) -> None: def stop_bundle(slug: str) -> None:
"""Idempotent: a missing container returns success.""" """Idempotent: a missing container returns success."""
container = bundle_container_name(slug) container = bundle_container_name(slug)
+1 -10
View File
@@ -117,21 +117,12 @@ def machine_create(
Smolfile because `--from` and `--smolfile` are themselves Smolfile because `--from` and `--smolfile` are themselves
mutually exclusive in smolvm 0.8.0 and we want `--from`'s mutually exclusive in smolvm 0.8.0 and we want `--from`'s
no-pull-at-start property. The flag form gives the same no-pull-at-start property. The flag form gives the same
result without the Smolfile complication. result without the Smolfile complication."""
`--net` is sent explicitly when `allow_cidrs` is non-empty.
smolvm 0.8.0's docs say `--allow-cidr` implies `--net`, but
empirically the implication only fires when no `--from` is
set `--from PATH --allow-cidr X/32` silently produces a
machine with `network: false` and no routes in the guest, so
the agent can't reach the bundle's pinned IP."""
args: list[str] = ["machine", "create"] args: list[str] = ["machine", "create"]
if image is not None: if image is not None:
args += ["--image", image] args += ["--image", image]
if from_path is not None: if from_path is not None:
args += ["--from", str(from_path)] args += ["--from", str(from_path)]
if allow_cidrs:
args.append("--net")
for cidr in allow_cidrs: for cidr in allow_cidrs:
args += ["--allow-cidr", cidr] args += ["--allow-cidr", cidr]
if env: if env:
-16
View File
@@ -600,22 +600,6 @@ 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
design pinned the bundle at a docker bridge IP and set TSI's
allowlist to `<bundle-ip>/32`. On Docker Desktop / macOS the
daemon runs inside its own Linux VM, so bridge IPs aren't
reachable from macOS networking — TSI's syscall impersonation
can't reach them. Resolution: publish each agent-facing bundle
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
bound to macOS's loopback** — postgres, dev servers, other
bottles' published ports, mDNSResponder, etc. The agent can't
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
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
+12 -4
View File
@@ -54,18 +54,26 @@ class TestImageId(unittest.TestCase):
self.assertIn("missing:tag", die.call_args.args[0]) self.assertIn("missing:tag", die.call_args.args[0])
class TestSave(unittest.TestCase): class TestTagPush(unittest.TestCase):
def test_save_runs_docker_save(self): def test_tag_runs_docker_tag(self):
with patch.object( with patch.object(
docker_mod.subprocess, "run", return_value=_ok(), docker_mod.subprocess, "run", return_value=_ok(),
) as run: ) as run:
docker_mod.save("claude-bottle:latest", "/tmp/img.tar") docker_mod.tag("claude-bottle:latest", "localhost:5000/cb:abc")
argv = run.call_args.args[0] argv = run.call_args.args[0]
self.assertEqual( self.assertEqual(
["docker", "save", "claude-bottle:latest", "-o", "/tmp/img.tar"], ["docker", "tag", "claude-bottle:latest", "localhost:5000/cb:abc"],
argv, argv,
) )
def test_push_runs_docker_push(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_ok(),
) as run:
docker_mod.push("localhost:5000/cb:abc")
argv = run.call_args.args[0]
self.assertEqual(["docker", "push", "localhost:5000/cb:abc"], argv)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+47 -156
View File
@@ -1,16 +1,15 @@
"""Unit: ephemeral local-registry helper (PRD 0023 chunk 4c). """Unit: ephemeral local-registry helper (PRD 0023 chunk 4c).
The helper brings up a `registry:2.8.3` container on a private The helper brings up a `registry:2.8.3` container on a random
docker network with a random host-side port, yields a loopback port, yields the port, and tears the container down on
`RegistryHandle`, and tears the container + network down on exit. exit. Tests mock `subprocess.run` + `socket.create_connection` so
Tests mock `subprocess.run` + `socket.create_connection` so they they run without docker."""
run without docker."""
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import unittest import unittest
from unittest.mock import patch from unittest.mock import call, patch
from claude_bottle.backend.smolmachines import local_registry from claude_bottle.backend.smolmachines import local_registry
@@ -21,81 +20,38 @@ def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess:
) )
def _fail(stderr: str = "boom") -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=[], returncode=1, stdout="", stderr=stderr,
)
# Run sequence per ephemeral_registry() call:
# docker network create -> ok
# docker run -d (registry) -> ok (container id)
# docker port (host port) -> ok (mapping line)
# docker rm -f (registry) -> ok (in finally)
# docker network rm -> ok (in finally)
def _stock_run_sequence(port_line: str = "0.0.0.0:54321\n"):
return [
_ok(), # docker network create
_ok(stdout="<container-id>\n"), # docker run
_ok(stdout=port_line), # docker port
_ok(), # docker rm -f
_ok(), # docker network rm
]
class TestEphemeralRegistry(unittest.TestCase): class TestEphemeralRegistry(unittest.TestCase):
def test_yields_handle_with_network_and_endpoints(self): def test_yields_host_port_parsed_from_docker_port(self):
# docker run + docker port + docker rm in that order; the
# port command returns `127.0.0.1:54321` for the loopback
# binding.
with patch.object( with patch.object(
local_registry.subprocess, "run", local_registry.subprocess, "run",
side_effect=_stock_run_sequence(), side_effect=[
_ok(stdout="<container-id>\n"),
_ok(stdout="127.0.0.1:54321\n"),
_ok(),
],
) as run, patch.object( ) as run, patch.object(
local_registry.socket, "create_connection", local_registry.socket, "create_connection",
return_value=_FakeSocket(), return_value=_FakeSocket(),
): ):
with local_registry.ephemeral_registry() as handle: with local_registry.ephemeral_registry() as port:
# push_endpoint points at the registry container by self.assertEqual(54321, port)
# its docker-network name on its container port.
self.assertTrue(
handle.push_endpoint.startswith(
"claude-bottle-registry-"
)
)
self.assertTrue(handle.push_endpoint.endswith(":5000"))
# pull_endpoint is the host-side mapping for smolvm.
self.assertEqual("localhost:54321", handle.pull_endpoint)
# network name is the per-session bridge crane joins.
self.assertTrue(
handle.network.startswith("claude-bottle-registry-net-")
)
# docker network create + docker run + docker port + rm -f + network rm
self.assertEqual(5, run.call_count)
def test_registry_run_publishes_random_port_across_interfaces(self): # docker run, docker port, docker rm -f
with patch.object( self.assertEqual(3, run.call_count)
local_registry.subprocess, "run", run_argv = run.call_args_list[0].args[0]
side_effect=_stock_run_sequence(),
) as run, patch.object(
local_registry.socket, "create_connection",
return_value=_FakeSocket(),
):
with local_registry.ephemeral_registry():
pass
# second call is the docker run for the registry
run_argv = run.call_args_list[1].args[0]
self.assertEqual(["docker", "run"], run_argv[:2]) self.assertEqual(["docker", "run"], run_argv[:2])
self.assertIn("--rm", run_argv) self.assertIn("--rm", run_argv)
# `-p 5000` (no IP prefix) — needed so the host-published # Loopback-only port binding so the registry isn't exposed
# port is reachable from BOTH the host (for smolvm) and the # on the LAN even briefly.
# docker daemon (for the docker port command to find it). self.assertIn("127.0.0.1::5000", run_argv)
self.assertIn("5000", run_argv)
# And the registry is attached to the same per-session
# network the crane push container joins.
self.assertIn("--network", run_argv)
def test_force_removes_container_and_network_on_clean_exit(self): def test_force_removes_container_on_clean_exit(self):
with patch.object( with patch.object(
local_registry.subprocess, "run", local_registry.subprocess, "run",
side_effect=_stock_run_sequence(), side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
) as run, patch.object( ) as run, patch.object(
local_registry.socket, "create_connection", local_registry.socket, "create_connection",
return_value=_FakeSocket(), return_value=_FakeSocket(),
@@ -103,16 +59,14 @@ class TestEphemeralRegistry(unittest.TestCase):
with local_registry.ephemeral_registry(): with local_registry.ephemeral_registry():
pass pass
# Last two calls are `docker rm -f <container>` then # Last call is `docker rm -f <name>`.
# `docker network rm <network>`. last_argv = run.call_args_list[-1].args[0]
argvs = [c.args[0] for c in run.call_args_list] self.assertEqual(["docker", "rm", "-f"], last_argv[:3])
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3])
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
def test_force_removes_on_exception_inside_with(self): def test_force_removes_container_on_exception_inside_with(self):
with patch.object( with patch.object(
local_registry.subprocess, "run", local_registry.subprocess, "run",
side_effect=_stock_run_sequence(), side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
) as run, patch.object( ) as run, patch.object(
local_registry.socket, "create_connection", local_registry.socket, "create_connection",
return_value=_FakeSocket(), return_value=_FakeSocket(),
@@ -121,15 +75,15 @@ class TestEphemeralRegistry(unittest.TestCase):
with local_registry.ephemeral_registry(): with local_registry.ephemeral_registry():
raise RuntimeError("inside with") raise RuntimeError("inside with")
# Both teardowns still ran. # rm -f still ran on exception.
argvs = [c.args[0] for c in run.call_args_list] last_argv = run.call_args_list[-1].args[0]
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3]) self.assertEqual(["docker", "rm", "-f"], last_argv[:3])
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
def test_wait_ready_times_out(self): def test_wait_ready_times_out_when_socket_never_connects(self):
# Drop the timeout to a value that fits the test budget.
with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object( with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object(
local_registry.subprocess, "run", local_registry.subprocess, "run",
side_effect=_stock_run_sequence(), side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()],
) as run, patch.object( ) as run, patch.object(
local_registry.socket, "create_connection", local_registry.socket, "create_connection",
side_effect=OSError("conn refused"), side_effect=OSError("conn refused"),
@@ -141,27 +95,18 @@ class TestEphemeralRegistry(unittest.TestCase):
with local_registry.ephemeral_registry(): with local_registry.ephemeral_registry():
self.fail("yield reached despite unreachable registry") self.fail("yield reached despite unreachable registry")
die.assert_called_once() die.assert_called_once()
# Teardown still ran via the finally blocks. # rm -f still ran (cleanup goes through the finally block).
argvs = [c.args[0] for c in run.call_args_list] last_argv = run.call_args_list[-1].args[0]
self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3]) self.assertEqual(["docker", "rm", "-f"], last_argv[:3])
self.assertEqual(["docker", "network", "rm"], argvs[-1][:3])
def test_unique_session_ids_per_call(self): def test_unique_container_name_per_call(self):
sessions: list[tuple[str, str]] = [] names: list[str] = []
def capture(argv, *a, **kw): def capture(argv, *a, **kw):
if argv[:3] == ["docker", "network", "create"]:
return _ok()
if argv[:2] == ["docker", "run"]: if argv[:2] == ["docker", "run"]:
# `--name <registry-name>` and `--network <net-name>` names.append(argv[argv.index("--name") + 1])
# both encode the session id. return _ok(stdout="cid\n" if argv[:2] == ["docker", "run"]
name = argv[argv.index("--name") + 1] else "127.0.0.1:1\n")
network = argv[argv.index("--network") + 1]
sessions.append((name, network))
return _ok(stdout="cid\n")
if argv[:2] == ["docker", "port"]:
return _ok(stdout="0.0.0.0:1\n")
return _ok()
with patch.object( with patch.object(
local_registry.subprocess, "run", side_effect=capture, local_registry.subprocess, "run", side_effect=capture,
@@ -174,64 +119,10 @@ class TestEphemeralRegistry(unittest.TestCase):
with local_registry.ephemeral_registry(): with local_registry.ephemeral_registry():
pass pass
self.assertEqual(2, len(sessions)) self.assertEqual(2, len(names))
self.assertNotEqual(sessions[0], sessions[1]) self.assertNotEqual(names[0], names[1])
for n in names:
self.assertTrue(n.startswith("claude-bottle-registry-"))
class TestCranePushTarball(unittest.TestCase):
def test_runs_crane_container_on_registry_network_with_insecure_flag(self):
handle = local_registry.RegistryHandle(
network="cb-registry-net-x",
push_endpoint="cb-registry-x:5000",
pull_endpoint="localhost:54321",
)
with patch.object(
local_registry.subprocess, "run", return_value=_ok(),
) as run:
local_registry.crane_push_tarball(
handle, "/tmp/img.tar", "cb-registry-x:5000/cb:abc",
)
argv = run.call_args.args[0]
# Joined to the same docker network so it can reach the
# registry by container name (no host port-forward needed
# for the push leg).
self.assertEqual("docker", argv[0])
self.assertEqual("run", argv[1])
self.assertIn("--rm", argv)
self.assertIn("--network", argv)
self.assertEqual(
"cb-registry-net-x", argv[argv.index("--network") + 1],
)
# The tarball is mounted read-only at /img.tar.
self.assertIn("-v", argv)
self.assertIn("/tmp/img.tar:/img.tar:ro", argv)
# And the crane command itself uses --insecure so plain
# HTTP is allowed against the registry container.
self.assertIn("push", argv)
self.assertIn("--insecure", argv)
self.assertIn("/img.tar", argv)
self.assertIn("cb-registry-x:5000/cb:abc", argv)
def test_dies_when_crane_returns_non_zero(self):
handle = local_registry.RegistryHandle(
network="cb-net", push_endpoint="cb:5000", pull_endpoint="localhost:1",
)
with patch.object(
local_registry.subprocess, "run",
return_value=_fail("push failed"),
), patch.object(
local_registry, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
local_registry.crane_push_tarball(
handle, "/tmp/img.tar", "cb:5000/cb:abc",
)
die.assert_called_once()
# Error message names what was being pushed where.
msg = die.call_args.args[0]
self.assertIn("/tmp/img.tar", msg)
self.assertIn("cb:5000/cb:abc", msg)
class _FakeSocket: class _FakeSocket:
+27 -46
View File
@@ -40,43 +40,32 @@ class TestEnsureSmolmachine(unittest.TestCase):
_prepare.docker_mod, "image_id", _prepare.docker_mod, "image_id",
return_value=f"sha256:{digest}fffffffffffffffff", return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object( ), patch.object(
_prepare.docker_mod, "save",
) as save, patch.object(
_prepare, "ephemeral_registry", _prepare, "ephemeral_registry",
) as registry, patch.object( ) as registry, patch.object(
_prepare, "crane_push_tarball", _prepare.docker_mod, "tag",
) as tag, patch.object(
_prepare.docker_mod, "push",
) as push, patch.object( ) as push, patch.object(
_prepare._smolvm, "pack_create", _prepare._smolvm, "pack_create",
) as pack: ) as pack:
result = _prepare._ensure_smolmachine("claude-bottle:latest") result = _prepare._ensure_smolmachine("claude-bottle:latest")
self.assertEqual(sidecar, result) self.assertEqual(sidecar, result)
# build still runs (Dockerfile edits land without manual rmi). # build still runs (Dockerfile edits land without manual rmi)
build.assert_called_once() build.assert_called_once()
# No save (500MB tarball), no registry, no push, no pack on # No registry, no tag, no push, no pack on cache hit.
# cache hit.
save.assert_not_called()
registry.assert_not_called() registry.assert_not_called()
tag.assert_not_called()
push.assert_not_called() push.assert_not_called()
pack.assert_not_called() pack.assert_not_called()
def test_cache_miss_runs_build_save_push_pack_in_order(self): def test_cache_miss_runs_build_tag_push_pack_in_order(self):
digest = "0123456789abcdef" digest = "0123456789abcdef"
# ephemeral_registry yields a RegistryHandle with the # ephemeral_registry is a context manager yielding the port.
# docker network + a push endpoint (container DNS) and
# pull endpoint (host port-forward).
from claude_bottle.backend.smolmachines.local_registry import (
RegistryHandle,
)
class _Reg: class _Reg:
def __enter__(self_inner): def __enter__(self_inner):
return RegistryHandle( return 54321
network="cb-net-xyz",
push_endpoint="cb-registry-xyz:5000",
pull_endpoint="localhost:54321",
)
def __exit__(self_inner, *exc): def __exit__(self_inner, *exc):
return False return False
@@ -94,13 +83,13 @@ class TestEnsureSmolmachine(unittest.TestCase):
_prepare.docker_mod, "image_id", _prepare.docker_mod, "image_id",
return_value=f"sha256:{digest}fffffffffffffffff", return_value=f"sha256:{digest}fffffffffffffffff",
), patch.object( ), patch.object(
_prepare.docker_mod, "save",
side_effect=record("save"),
) as save, patch.object(
_prepare, "ephemeral_registry", _prepare, "ephemeral_registry",
return_value=_Reg(), return_value=_Reg(),
), patch.object( ), patch.object(
_prepare, "crane_push_tarball", _prepare.docker_mod, "tag",
side_effect=record("tag"),
) as tag, patch.object(
_prepare.docker_mod, "push",
side_effect=record("push"), side_effect=record("push"),
) as push, patch.object( ) as push, patch.object(
_prepare._smolvm, "pack_create", _prepare._smolvm, "pack_create",
@@ -108,31 +97,23 @@ class TestEnsureSmolmachine(unittest.TestCase):
) as pack: ) as pack:
_prepare._ensure_smolmachine("claude-bottle:latest") _prepare._ensure_smolmachine("claude-bottle:latest")
# Build → save → push → pack in that order. No `docker # build first (no point pushing if the build fails), then
# push` (the daemon's HTTPS-by-default path is what we're # tag → push → pack against the registry port.
# sidestepping). self.assertEqual(["build", "tag", "push", "pack"], calls)
self.assertEqual(["build", "save", "push", "pack"], calls)
# docker save targets a per-digest tarball alongside the # tag goes from the source ref to a localhost:<port> ref
# cached sidecar. # with the digest as the tag suffix (so different builds
save_args = save.call_args.args # land on different tags in the registry).
self.assertEqual("claude-bottle:latest", save_args[0]) tag_args = tag.call_args.args
self.assertTrue(save_args[1].endswith(f"{digest}.image.tar")) self.assertEqual("claude-bottle:latest", tag_args[0])
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", tag_args[1])
# crane push runs against the push_endpoint (container DNS # push targets the same localhost ref tag picks.
# on the registry network) with the digest as the tag.
push_args = push.call_args.args push_args = push.call_args.args
self.assertEqual( self.assertEqual(f"localhost:54321/claude-bottle:{digest}", push_args[0])
f"cb-registry-xyz:5000/claude-bottle:{digest}", push_args[2], # pack_create reads from the registry ref, writes the
) # binary alongside the cached sidecar.
# pack_create reads from the pull_endpoint (host port-
# forward, smolvm is on the host). Same repo+tag, just a
# different routing hostname — the registry stores one blob.
pack_args = pack.call_args.args pack_args = pack.call_args.args
self.assertEqual( self.assertEqual(f"localhost:54321/claude-bottle:{digest}", pack_args[0])
f"localhost:54321/claude-bottle:{digest}", pack_args[0],
)
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine")) self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
+13 -45
View File
@@ -44,8 +44,6 @@ def _plan(
pipelock_ca_path: Path = Path(), pipelock_ca_path: Path = Path(),
supervise: bool = False, supervise: bool = False,
bundle_ip: str = "192.168.50.2", bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555",
agent_supervise_url: str = "http://127.0.0.1:55556/",
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
bottle_json: dict = {} bottle_json: dict = {}
if git: if git:
@@ -113,8 +111,6 @@ def _plan(
mitmproxy_ca_cert_only_host_path=egress_ca_path, mitmproxy_ca_cert_only_host_path=egress_ca_path,
), ),
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
agent_git_gate_host=agent_git_gate_host,
agent_supervise_url=agent_supervise_url,
) )
@@ -307,38 +303,21 @@ class TestProvisionCA(unittest.TestCase):
def tearDown(self): def tearDown(self):
self._tmp.cleanup() self._tmp.cleanup()
# provision_ca dies hard if update-ca-certificates' stdout
# doesn't include "1 added"; supply a stock success return
# so the bulk of the tests below exercise the happy path.
_UPDATE_OK = SmolvmRunResult(
returncode=0,
stdout="Updating certificates in /etc/ssl/certs...\n1 added, 0 removed; done.\n",
stderr="",
)
def test_pipelock_path_when_no_routes(self): def test_pipelock_path_when_no_routes(self):
plan = _plan(pipelock_ca_path=self.pipelock_ca) plan = _plan(pipelock_ca_path=self.pipelock_ca)
with patch( with patch(
"claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp" "claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
) as cp, patch( ) as cp, patch(
"claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec", "claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec"
return_value=self._UPDATE_OK,
) as ex: ) as ex:
_ca.provision_ca(plan, "claude-bottle-demo-abc12") _ca.provision_ca(plan, "claude-bottle-demo-abc12")
cp.assert_called_once_with( cp.assert_called_once_with(
str(self.pipelock_ca), str(self.pipelock_ca),
"claude-bottle-demo-abc12:" + _ca.AGENT_CA_PATH, "claude-bottle-demo-abc12:" + _ca.AGENT_CA_PATH,
) )
# chmod + chown + update-ca-certificates are now folded argvs = [c.args[1] for c in ex.call_args_list]
# into one `sh -c` invocation (working around a smolvm self.assertIn(["chmod", "644", _ca.AGENT_CA_PATH], argvs)
# exec warm-up SIGKILL race), so we look at the single self.assertIn(["update-ca-certificates"], argvs)
# exec's argv rather than expecting separate calls.
ex.assert_called_once()
argv = ex.call_args.args[1]
self.assertEqual("sh", argv[0])
self.assertEqual("-c", argv[1])
self.assertIn("chmod 644", argv[2])
self.assertIn("update-ca-certificates", argv[2])
def test_egress_path_when_routes_declared(self): def test_egress_path_when_routes_declared(self):
plan = _plan( plan = _plan(
@@ -349,8 +328,7 @@ class TestProvisionCA(unittest.TestCase):
with patch( with patch(
"claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp" "claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_cp"
) as cp, patch( ) as cp, patch(
"claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec", "claude_bottle.backend.smolmachines.provision.ca._smolvm.machine_exec"
return_value=self._UPDATE_OK,
): ):
_ca.provision_ca(plan, "claude-bottle-demo-abc12") _ca.provision_ca(plan, "claude-bottle-demo-abc12")
# When routes are declared, egress is the agent's first hop, # When routes are declared, egress is the agent's first hop,
@@ -434,10 +412,9 @@ class TestProvisionGit(unittest.TestCase):
cp.assert_not_called() cp.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self): def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via # Smolmachines's TSI-allowlisted guest has no DNS resolver,
# `127.0.0.1:<host port>` — the bundle's git-gate port is # so the insteadOf URL has to be IP+port rather than the
# published on host loopback at launch time, and the plan # docker backend's `git-gate` short alias.
# carries the discovered host port (here mocked to 9418).
plan = _plan( plan = _plan(
git=[GitEntry( git=[GitEntry(
Name="claude-bottle", Name="claude-bottle",
@@ -445,7 +422,7 @@ class TestProvisionGit(unittest.TestCase):
IdentityFile="~/.ssh/id_ed25519", IdentityFile="~/.ssh/id_ed25519",
)], )],
stage_dir=self.stage, stage_dir=self.stage,
agent_git_gate_host="127.0.0.1:9418", bundle_ip="192.168.99.2",
) )
with patch( with patch(
"claude_bottle.backend.smolmachines.provision.git._smolvm.machine_cp" "claude_bottle.backend.smolmachines.provision.git._smolvm.machine_cp"
@@ -460,7 +437,7 @@ class TestProvisionGit(unittest.TestCase):
self.assertEqual(self.stage, staged_path.parent) self.assertEqual(self.stage, staged_path.parent)
content = staged_path.read_text() content = staged_path.read_text()
self.assertIn( self.assertIn(
'[url "git://127.0.0.1:9418/claude-bottle.git"]', content, '[url "git://192.168.99.2:9418/claude-bottle.git"]', content,
) )
self.assertIn( self.assertIn(
"\tinsteadOf = ssh://git@host/repo.git", content, "\tinsteadOf = ssh://git@host/repo.git", content,
@@ -476,10 +453,7 @@ class TestProvisionSupervise(unittest.TestCase):
ex.assert_not_called() ex.assert_not_called()
def test_calls_claude_mcp_add_when_supervise_enabled(self): def test_calls_claude_mcp_add_when_supervise_enabled(self):
plan = _plan( plan = _plan(supervise=True, bundle_ip="192.168.50.2")
supervise=True,
agent_supervise_url="http://127.0.0.1:9100/",
)
with patch( with patch(
"claude_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec", "claude_bottle.backend.smolmachines.provision.supervise._smolvm.machine_exec",
return_value=SmolvmRunResult(returncode=0, stdout="", stderr=""), return_value=SmolvmRunResult(returncode=0, stdout="", stderr=""),
@@ -487,20 +461,14 @@ class TestProvisionSupervise(unittest.TestCase):
_supervise.provision_supervise(plan, "claude-bottle-demo-abc12") _supervise.provision_supervise(plan, "claude-bottle-demo-abc12")
ex.assert_called_once() ex.assert_called_once()
argv = ex.call_args.args[1] argv = ex.call_args.args[1]
# `claude mcp add --scope user` writes to ~/.claude.json, # claude mcp add --scope user --transport http supervise <url>
# and the agent is the `node` user — switch UID + set
# HOME so the config lands in /home/node/.claude.json,
# not root's. URL is the agent-side endpoint (host
# loopback + discovered port), not the docker bridge IP.
self.assertEqual( self.assertEqual(
[ [
"runuser", "-u", "node", "--",
"env", "HOME=/home/node",
"claude", "mcp", "add", "claude", "mcp", "add",
"--scope", "user", "--scope", "user",
"--transport", "http", "--transport", "http",
"supervise", "supervise",
"http://127.0.0.1:9100/", "http://192.168.50.2:9100/",
], ],
argv, argv,
) )
-8
View File
@@ -82,20 +82,12 @@ class TestArgvShapes(unittest.TestCase):
self.assertEqual("smolvm", argv[0]) self.assertEqual("smolvm", argv[0])
self.assertIn("--from", argv) self.assertIn("--from", argv)
self.assertIn("/stage/agent.smolmachine", argv) self.assertIn("/stage/agent.smolmachine", argv)
# `--net` is explicit because smolvm 0.8.0's implied-net
# from --allow-cidr doesn't fire when --from is set.
self.assertIn("--net", argv)
self.assertIn("--allow-cidr", argv) self.assertIn("--allow-cidr", argv)
self.assertIn("192.168.50.2/32", argv) self.assertIn("192.168.50.2/32", argv)
self.assertIn("-e", argv) self.assertIn("-e", argv)
self.assertIn("HTTPS_PROXY=http://192.168.50.2:8888", argv) self.assertIn("HTTPS_PROXY=http://192.168.50.2:8888", argv)
self.assertEqual("agent-xyz", argv[-1]) self.assertEqual("agent-xyz", argv[-1])
def test_machine_create_omits_net_when_no_allow_cidrs(self):
with self._patch_run() as m:
machine_create("agent-xyz", from_path=Path("/x.smolmachine"))
self.assertNotIn("--net", m.call_args.args[0])
def test_machine_start_uses_dash_name(self): def test_machine_start_uses_dash_name(self):
# `start` is the --name flag form, NOT positional. # `start` is the --name flag form, NOT positional.
with self._patch_run() as m: with self._patch_run() as m: