From f4026ea3ae895b38f7447fbfe00d32056af73399 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 14:41:26 -0400 Subject: [PATCH 01/13] =?UTF-8?q?fix(smolmachines):=20docker=20push=20fail?= =?UTF-8?q?s=20on=20Docker=20Desktop=20=E2=80=94=20daemon-side=20route=20d?= =?UTF-8?q?iffers=20from=20host=20loopback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `./cli.py start ` under CLAUDE_BOTTLE_BACKEND=smolmachines died at `docker push localhost:/claude-bottle:` with `Get "http://localhost:/v2/": context deadline exceeded`. Cause: chunk 4c bound the ephemeral registry to `127.0.0.1::5000` and used `localhost:` as the only image-ref hostname. On Docker Desktop the daemon runs inside its own Linux VM — its `localhost` is the VM's loopback, not the host's, so the daemon cannot reach a registry bound to the host's 127.0.0.1. Fix: bind the registry to all interfaces (`-p :5000`) so it's reachable from both sides, and yield two endpoints: - `daemon_endpoint` — `host.docker.internal:` on Docker Desktop (daemon-side hostname for the host VM gateway), `localhost:` on a native Linux daemon that shares the host's network namespace. Used for `docker tag` + `docker push`. - `host_endpoint` — always `localhost:`. Used for `smolvm pack create`, which runs as a host process. The registry stores images by repo+tag, so a push to `host.docker.internal:/cb:` and a pull from `localhost:/cb:` resolve to the same blob — the hostname in a ref is just routing. Detection uses `docker info --format '{{.OperatingSystem}}'`, which returns "Docker Desktop" on macOS/Windows Desktop and the host's OS name on native daemons. Trade-off: all-interface binding briefly publishes the registry on every interface (~5-10s during prepare). The pushed image is built from the public repo Dockerfile (no secrets), the port is random, and the window is short — acceptable for v1 of a personal dev tool. Co-Authored-By: Claude Opus 4.7 --- .../backend/smolmachines/local_registry.py | 109 ++++++++++++++---- claude_bottle/backend/smolmachines/prepare.py | 33 +++--- .../unit/test_smolmachines_local_registry.py | 109 ++++++++++++++---- tests/unit/test_smolmachines_prepare_image.py | 40 +++++-- 4 files changed, 221 insertions(+), 70 deletions(-) diff --git a/claude_bottle/backend/smolmachines/local_registry.py b/claude_bottle/backend/smolmachines/local_registry.py index 7b27aa3..3af46f7 100644 --- a/claude_bottle/backend/smolmachines/local_registry.py +++ b/claude_bottle/backend/smolmachines/local_registry.py @@ -5,13 +5,36 @@ conversion path (PRD 0023 chunk 4c). can't read the local docker daemon's image cache, an OCI layout directory, or a `docker save` tarball. To convert the agent's Dockerfile-built image into a `.smolmachine` artifact we run a -short-lived `registry:2.8.3` container on `127.0.0.1:`, -push the locally-tagged image into it, and let smolvm pull from -there. The registry container is torn down as soon as the pack -completes. +short-lived `registry:2.8.3` container, push the locally-tagged +image into it, and let smolvm pull from there. The registry +container is torn down as soon as the pack completes. -Loopback-only bind + the host's docker layer cache mean the round -trip is fast (~5s) and there's no exposed surface on the LAN.""" +Two routing hostnames, one registry container. On Docker Desktop +(macOS/Windows) the docker daemon runs inside its own Linux VM, +so its `localhost` is *not* the host's loopback — a registry +bound to `127.0.0.1::` on the host is unreachable from the +daemon side, and `docker push` fails with `context deadline +exceeded`. The fix: bind to all interfaces so both routes work, +and yield two refs: + + - `daemon_endpoint`: how the docker CLI/daemon dials the + registry (`host.docker.internal:` on Docker Desktop, + `localhost:` on a native Linux daemon that shares the + host's network namespace). + - `host_endpoint`: how `smolvm pack create` (a host process) + dials the registry. Always `localhost:` — the port + binding includes loopback either way. + +The registry stores images by repo+tag; the hostname in the ref +is just routing, so a push to `host.docker.internal:/cb:abc` +and a pull of `localhost:/cb:abc` hit the same stored +blob. + +Trade-off: binding to all interfaces puts the registry on every +network interface briefly (~5-10s during prepare). The agent +image we push is built from the repo's public Dockerfile — no +secrets in it — and the user is on their own machine; the LAN +exposure window is short and the contents non-sensitive.""" from __future__ import annotations @@ -21,6 +44,7 @@ import subprocess import time import uuid from contextlib import contextmanager +from dataclasses import dataclass from typing import Iterator from ...log import die @@ -40,10 +64,21 @@ REGISTRY_IMAGE = os.environ.get( _READY_TIMEOUT_S = 10.0 +@dataclass(frozen=True) +class RegistryEndpoints: + """The two `:` strings to embed in image refs. They + point at the same registry container; only the routing + hostname differs.""" + + daemon_endpoint: str + host_endpoint: str + + @contextmanager -def ephemeral_registry() -> Iterator[int]: - """Bring up a `registry:2.8.3` container on a random loopback - port, yield the port, force-remove the container on exit. +def ephemeral_registry() -> Iterator[RegistryEndpoints]: + """Bring up a `registry:2.8.3` container on a random host port, + yield the daemon-side + host-side endpoints, force-remove the + container on exit. The container is started with `--rm` so a clean exit cleans up on its own; the `finally` block force-removes on abnormal exit @@ -53,10 +88,14 @@ def ephemeral_registry() -> Iterator[int]: [ "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", + # `-p :5000` (no IP prefix) binds the container's port + # 5000 on a random host port across all interfaces. The + # registry container itself listens on 0.0.0.0:5000 + # internally; binding to all interfaces is necessary for + # Docker Desktop's daemon to reach it via + # host.docker.internal — a 127.0.0.1-only host binding + # is invisible to a daemon running in its own VM. + "-p", "5000", REGISTRY_IMAGE, ], check=True, @@ -65,7 +104,11 @@ def ephemeral_registry() -> Iterator[int]: try: port = _host_port(name) _wait_ready(port) - yield port + daemon_host = _daemon_side_hostname() + yield RegistryEndpoints( + daemon_endpoint=f"{daemon_host}:{port}", + host_endpoint=f"localhost:{port}", + ) finally: subprocess.run( ["docker", "rm", "-f", name], @@ -74,11 +117,34 @@ def ephemeral_registry() -> Iterator[int]: ) +def _daemon_side_hostname() -> str: + """Pick the hostname the docker daemon should use to dial the + registry. On Docker Desktop the daemon runs in its own Linux + VM and only sees the host via `host.docker.internal`; on + native Linux the daemon shares the host's network namespace + and `localhost` works. + + `docker info --format '{{.OperatingSystem}}'` returns + `"Docker Desktop"` on macOS / Windows Desktop installs (and on + Linux Desktop, which also uses a VM). Anything else (e.g. + `"Debian GNU/Linux 12 (bookworm)"`) is a native daemon.""" + r = subprocess.run( + ["docker", "info", "--format", "{{.OperatingSystem}}"], + capture_output=True, + text=True, + check=False, + ) + operating_system = (r.stdout or "").strip() + if operating_system == "Docker Desktop": + return "host.docker.internal" + return "localhost" + + def _host_port(name: str) -> int: """Resolve the host-side port docker mapped to the registry's - container port 5000. `docker port 5000/tcp` returns one or - more `host:port` lines; the loopback-only -p binding ensures we - get exactly `127.0.0.1:`.""" + container port 5000. `docker port 5000/tcp` returns one + or more `host:port` lines (one per address family) — we take + the first IPv4 line.""" r = subprocess.run( ["docker", "port", name, "5000/tcp"], capture_output=True, @@ -90,8 +156,8 @@ def _host_port(name: str) -> int: f"docker port {name} 5000/tcp failed: " f"{(r.stderr or '').strip() or ''}" ) - # `127.0.0.1:54321\n` — split on the last colon to handle the - # `host:port` shape without parsing IP literals. + # `0.0.0.0:54321\n[::]:54321\n` — take the first line, split + # on the last colon to handle either IPv4 or IPv6 host syntax. line = (r.stdout or "").splitlines()[0].strip() _, _, port_str = line.rpartition(":") try: @@ -107,7 +173,10 @@ def _wait_ready(port: int) -> None: A successful TCP connect is sufficient — registry:2.8.3 binds after it's ready to serve `/v2/` requests, so the push that - follows will land on a working server.""" + follows will land on a working server. We probe loopback + specifically (not host.docker.internal) because this helper + runs on the host, and 0.0.0.0-bound ports are reachable via + 127.0.0.1 too.""" deadline = time.monotonic() + _READY_TIMEOUT_S last_err: Exception | None = None while time.monotonic() < deadline: diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index bd9d258..dfeeef5 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -184,14 +184,20 @@ def _ensure_smolmachine(image_ref: str) -> Path: a launcher binary at `.smolmachine` plus the sidecar alongside it; the sidecar is the actual artifact). - Conversion path: `docker build` (the existing layer cache makes - no-change rebuilds cheap) → `docker tag` with a - `localhost:/...` ref → bring up the ephemeral registry - container → `docker push` into it → `smolvm pack create --image - ` → tear down the registry. 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.""" + Conversion path: `docker build` (the existing layer cache + makes no-change rebuilds cheap) → `docker tag` + `docker push` + using the daemon-side endpoint (`host.docker.internal:` + on Docker Desktop, `localhost:` on native Linux) → + `smolvm pack create --image ` using the + host-side endpoint (always `localhost:` — smolvm is a + host process) → tear down the registry. The two endpoints + route to the same registry container; only the hostname + differs because the docker daemon (on Docker Desktop) doesn't + share the host's loopback. + + 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) docker_mod.build_image(image_ref, _REPO_DIR) # `sha256:abcd...` -> `abcd...` first 16 chars: short enough to @@ -202,9 +208,10 @@ def _ensure_smolmachine(image_ref: str) -> Path: sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine" if sidecar.is_file(): return sidecar - with ephemeral_registry() as port: - local_ref = f"localhost:{port}/claude-bottle:{digest}" - docker_mod.tag(image_ref, local_ref) - docker_mod.push(local_ref) - _smolvm.pack_create(local_ref, binary) + with ephemeral_registry() as endpoints: + push_ref = f"{endpoints.daemon_endpoint}/claude-bottle:{digest}" + pack_ref = f"{endpoints.host_endpoint}/claude-bottle:{digest}" + docker_mod.tag(image_ref, push_ref) + docker_mod.push(push_ref) + _smolvm.pack_create(pack_ref, binary) return sidecar diff --git a/tests/unit/test_smolmachines_local_registry.py b/tests/unit/test_smolmachines_local_registry.py index 4e08269..babf5ee 100644 --- a/tests/unit/test_smolmachines_local_registry.py +++ b/tests/unit/test_smolmachines_local_registry.py @@ -1,15 +1,15 @@ """Unit: ephemeral local-registry helper (PRD 0023 chunk 4c). The helper brings up a `registry:2.8.3` container on a random -loopback port, yields the port, and tears the container down on -exit. Tests mock `subprocess.run` + `socket.create_connection` so -they run without docker.""" +host port, yields a `(daemon_endpoint, host_endpoint)` pair, and +tears the container down on exit. Tests mock `subprocess.run` + +`socket.create_connection` so they run without docker.""" from __future__ import annotations import subprocess import unittest -from unittest.mock import call, patch +from unittest.mock import patch from claude_bottle.backend.smolmachines import local_registry @@ -20,38 +20,93 @@ def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: ) +# `docker info` always runs once per ephemeral_registry() to pick +# the daemon-side hostname; the run sequence is therefore +# (docker run, docker port, docker info, docker rm). Helpers below +# build a stock side_effect that covers all four. +def _stock_run_sequence( + *, + port: str = "0.0.0.0:54321\n", + operating_system: str = "Docker Desktop\n", +): + return [ + _ok(stdout="\n"), # docker run + _ok(stdout=port), # docker port + _ok(stdout=operating_system), # docker info + _ok(), # docker rm -f + ] + + class TestEphemeralRegistry(unittest.TestCase): - 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. + def test_yields_endpoints_with_docker_desktop_routing(self): + # On Docker Desktop the daemon runs in its own VM, so the + # registry has to be addressed by host.docker.internal for + # docker push to work; smolvm (host process) still uses + # localhost. with patch.object( local_registry.subprocess, "run", - side_effect=[ - _ok(stdout="\n"), - _ok(stdout="127.0.0.1:54321\n"), - _ok(), - ], + side_effect=_stock_run_sequence(operating_system="Docker Desktop\n"), + ), patch.object( + local_registry.socket, "create_connection", + return_value=_FakeSocket(), + ): + with local_registry.ephemeral_registry() as endpoints: + self.assertEqual( + "host.docker.internal:54321", endpoints.daemon_endpoint, + ) + self.assertEqual( + "localhost:54321", endpoints.host_endpoint, + ) + + def test_yields_endpoints_with_native_linux_routing(self): + # On a native Linux daemon the daemon shares the host's + # network namespace, so localhost reaches the registry from + # both sides. + with patch.object( + local_registry.subprocess, "run", + side_effect=_stock_run_sequence( + operating_system="Debian GNU/Linux 12 (bookworm)\n", + ), + ), patch.object( + local_registry.socket, "create_connection", + return_value=_FakeSocket(), + ): + with local_registry.ephemeral_registry() as endpoints: + self.assertEqual( + "localhost:54321", endpoints.daemon_endpoint, + ) + self.assertEqual( + "localhost:54321", endpoints.host_endpoint, + ) + + def test_runs_docker_with_all_interface_bind(self): + # `-p 5000` (no IP prefix) binds the container's port 5000 + # on a random host port across all interfaces — needed so + # Docker Desktop's daemon can reach the registry via + # host.docker.internal. The 127.0.0.1-only bind we used + # previously was invisible to the daemon's VM. + with patch.object( + local_registry.subprocess, "run", + side_effect=_stock_run_sequence(), ) as run, patch.object( local_registry.socket, "create_connection", return_value=_FakeSocket(), ): - with local_registry.ephemeral_registry() as port: - self.assertEqual(54321, port) + with local_registry.ephemeral_registry(): + pass - # docker run, docker port, docker rm -f - self.assertEqual(3, run.call_count) run_argv = run.call_args_list[0].args[0] self.assertEqual(["docker", "run"], run_argv[:2]) self.assertIn("--rm", run_argv) - # Loopback-only port binding so the registry isn't exposed - # on the LAN even briefly. - self.assertIn("127.0.0.1::5000", run_argv) + self.assertIn("5000", run_argv) + # Explicitly NOT the loopback-only form — that one's broken + # under Docker Desktop. + self.assertNotIn("127.0.0.1::5000", run_argv) def test_force_removes_container_on_clean_exit(self): with patch.object( local_registry.subprocess, "run", - side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], + side_effect=_stock_run_sequence(), ) as run, patch.object( local_registry.socket, "create_connection", return_value=_FakeSocket(), @@ -66,7 +121,7 @@ class TestEphemeralRegistry(unittest.TestCase): def test_force_removes_container_on_exception_inside_with(self): with patch.object( local_registry.subprocess, "run", - side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], + side_effect=_stock_run_sequence(), ) as run, patch.object( local_registry.socket, "create_connection", return_value=_FakeSocket(), @@ -83,7 +138,7 @@ class TestEphemeralRegistry(unittest.TestCase): # Drop the timeout to a value that fits the test budget. with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object( local_registry.subprocess, "run", - side_effect=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], + side_effect=_stock_run_sequence(), ) as run, patch.object( local_registry.socket, "create_connection", side_effect=OSError("conn refused"), @@ -105,8 +160,12 @@ class TestEphemeralRegistry(unittest.TestCase): def capture(argv, *a, **kw): if argv[:2] == ["docker", "run"]: names.append(argv[argv.index("--name") + 1]) - return _ok(stdout="cid\n" if argv[:2] == ["docker", "run"] - else "127.0.0.1:1\n") + return _ok(stdout="cid\n") + if argv[:2] == ["docker", "port"]: + return _ok(stdout="0.0.0.0:1\n") + if argv[:2] == ["docker", "info"]: + return _ok(stdout="Docker Desktop\n") + return _ok() with patch.object( local_registry.subprocess, "run", side_effect=capture, diff --git a/tests/unit/test_smolmachines_prepare_image.py b/tests/unit/test_smolmachines_prepare_image.py index 7d046f6..afc0804 100644 --- a/tests/unit/test_smolmachines_prepare_image.py +++ b/tests/unit/test_smolmachines_prepare_image.py @@ -62,10 +62,19 @@ class TestEnsureSmolmachine(unittest.TestCase): def test_cache_miss_runs_build_tag_push_pack_in_order(self): digest = "0123456789abcdef" - # ephemeral_registry is a context manager yielding the port. + # ephemeral_registry yields a RegistryEndpoints with two + # routing hostnames — daemon-side for docker push, + # host-side for smolvm pack create. + from claude_bottle.backend.smolmachines.local_registry import ( + RegistryEndpoints, + ) + class _Reg: def __enter__(self_inner): - return 54321 + return RegistryEndpoints( + daemon_endpoint="host.docker.internal:54321", + host_endpoint="localhost:54321", + ) def __exit__(self_inner, *exc): return False @@ -98,22 +107,29 @@ class TestEnsureSmolmachine(unittest.TestCase): _prepare._ensure_smolmachine("claude-bottle:latest") # build first (no point pushing if the build fails), then - # tag → push → pack against the registry port. + # tag → push → pack against the registry endpoints. self.assertEqual(["build", "tag", "push", "pack"], calls) - # tag goes from the source ref to a localhost: ref - # with the digest as the tag suffix (so different builds - # land on different tags in the registry). + # tag + push target the daemon-side endpoint (host.docker + # .internal on Docker Desktop, since the daemon's + # localhost is its own VM's loopback). tag_args = tag.call_args.args self.assertEqual("claude-bottle:latest", tag_args[0]) - self.assertEqual(f"localhost:54321/claude-bottle:{digest}", tag_args[1]) - # push targets the same localhost ref tag picks. + self.assertEqual( + f"host.docker.internal:54321/claude-bottle:{digest}", tag_args[1], + ) push_args = push.call_args.args - self.assertEqual(f"localhost:54321/claude-bottle:{digest}", push_args[0]) - # pack_create reads from the registry ref, writes the - # binary alongside the cached sidecar. + self.assertEqual( + f"host.docker.internal:54321/claude-bottle:{digest}", push_args[0], + ) + # pack_create reads from the host-side endpoint (smolvm is + # a host process and can only resolve localhost). The + # registry stores images by repo+tag, so both endpoints + # hit the same blob. pack_args = pack.call_args.args - self.assertEqual(f"localhost:54321/claude-bottle:{digest}", pack_args[0]) + self.assertEqual( + f"localhost:54321/claude-bottle:{digest}", pack_args[0], + ) self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine")) From 47eb56bd104483ce0951096544cf9b9f73bf4cc8 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 14:52:40 -0400 Subject: [PATCH 02/13] fix(smolmachines): use containerized crane to push, bypassing docker daemon's HTTPS preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (`host.docker.internal:` for daemon-side push) still failed: Get "https://host.docker.internal:53958/v2/": http: server gave HTTP response to HTTPS client `host.docker.internal` is reachable from Docker Desktop's daemon VM but isn't in the daemon's default insecure-registries CIDRs (only `::1/128` and `127.0.0.0/8` are), so docker push tries HTTPS, hits a plain-HTTP registry, and refuses. The daemon.json fix (`"insecure-registries": ["host.docker.internal"]`) works but is a one-time manual step in Docker Desktop's UI — not something we can do for the user. Sidestep the daemon push entirely: 1. docker build (as before) — local layer cache makes no-change rebuilds cheap. 2. docker save the image to a per-digest tarball alongside the cached `.smolmachine`. 3. Start an ephemeral registry container on a per-session docker network, with `-p :5000` so the host can also reach it for the pack step. 4. docker run a one-shot crane container on the SAME network, mount the tarball, `crane push --insecure /img.tar :5000/...`. Container DNS resolves the registry on the network; `--insecure` forces plain HTTP. 5. `smolvm pack create --image localhost:/...` from the host. smolvm's bundled crane auto-falls-back to HTTP for localhost addresses, so no insecure-registries config is needed on that side. 6. Tear down everything; reap the tarball (registries hold the same bytes, no need to keep both around). Net effect: the docker daemon never does an HTTP/HTTPS-policy decision on our behalf. `docker push` is gone from the prepare path; `docker save`, `docker network create`, `docker run` (for registry + crane) replace it. Tested end-to-end on Docker Desktop / macOS: `_ensure_smolmachine ("claude-bottle:latest")` produces a 204MB `.smolmachine.smolmachine` artifact. Adds: - backend/docker/util.py:save() — thin docker save wrapper. - local_registry.crane_push_tarball() — one-shot crane run on the registry's network. - CRANE_IMAGE constant pinned by digest (gcr.io/go-containerregistry/crane@sha256:0ae17ecb...). Removes: - backend/docker/util.py:tag() / push() — unused without daemon push. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/docker/util.py | 21 +- .../backend/smolmachines/local_registry.py | 243 ++++++++++-------- claude_bottle/backend/smolmachines/prepare.py | 38 +-- tests/unit/test_docker_util_image.py | 16 +- .../unit/test_smolmachines_local_registry.py | 216 ++++++++++------ tests/unit/test_smolmachines_prepare_image.py | 71 ++--- 6 files changed, 347 insertions(+), 258 deletions(-) diff --git a/claude_bottle/backend/docker/util.py b/claude_bottle/backend/docker/util.py index 6599da5..0854f5b 100644 --- a/claude_bottle/backend/docker/util.py +++ b/claude_bottle/backend/docker/util.py @@ -166,18 +166,15 @@ def image_id(ref: str) -> str: return r.stdout.strip() -def tag(src: str, dst: str) -> None: - """`docker tag SRC DST`. Idempotent. Used by smolmachines prepare - to retag the locally-built image into a localhost:/... ref - that the ephemeral registry will accept.""" - subprocess.run(["docker", "tag", src, dst], check=True) - - -def push(ref: str) -> None: - """`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 save(ref: str, output: str) -> None: + """`docker save REF -o OUTPUT`. Writes a tarball of the image + layers + manifest to the host path. Used by smolmachines + prepare to hand the agent image to a containerized crane that + pushes it to the ephemeral registry — bypassing the docker + daemon's `docker push` (which on Docker Desktop can't reach a + host-loopback registry and refuses plain-HTTP pushes to + non-loopback hosts).""" + subprocess.run(["docker", "save", ref, "-o", output], check=True) def _silent_run(cmd: Iterable[str]) -> int: diff --git a/claude_bottle/backend/smolmachines/local_registry.py b/claude_bottle/backend/smolmachines/local_registry.py index 3af46f7..8977f37 100644 --- a/claude_bottle/backend/smolmachines/local_registry.py +++ b/claude_bottle/backend/smolmachines/local_registry.py @@ -1,40 +1,37 @@ """Ephemeral local OCI registry for the smolmachines agent-image conversion path (PRD 0023 chunk 4c). -`smolvm pack create --image ` only accepts registry refs — it -can't read the local docker daemon's image cache, an OCI layout -directory, or a `docker save` tarball. To convert the agent's -Dockerfile-built image into a `.smolmachine` artifact we run a -short-lived `registry:2.8.3` container, push the locally-tagged -image into it, and let smolvm pull from there. The registry -container is torn down as soon as the pack completes. +`smolvm pack create --image ` only accepts OCI registry refs +— it can't read the local docker daemon's image cache, an OCI +layout directory, or a `docker save` tarball. To convert the +agent's Dockerfile-built image into a `.smolmachine` artifact we +spin up a short-lived `registry:2.8.3` container alongside a +`crane` helper container on a private docker network, push via +`crane push --insecure :5000/...`, +and let smolvm pull from the registry's published host port. The +network + both containers are torn down after the pack completes. -Two routing hostnames, one registry container. On Docker Desktop -(macOS/Windows) the docker daemon runs inside its own Linux VM, -so its `localhost` is *not* the host's loopback — a registry -bound to `127.0.0.1::` on the host is unreachable from the -daemon side, and `docker push` fails with `context deadline -exceeded`. The fix: bind to all interfaces so both routes work, -and yield two refs: +Why this two-container dance instead of plain `docker push`: +- Docker Desktop's daemon runs in its own Linux VM, so its + `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). - - `daemon_endpoint`: how the docker CLI/daemon dials the - registry (`host.docker.internal:` on Docker Desktop, - `localhost:` on a native Linux daemon that shares the - host's network namespace). - - `host_endpoint`: how `smolvm pack create` (a host process) - dials the registry. Always `localhost:` — the port - binding includes loopback either way. - -The registry stores images by repo+tag; the hostname in the ref -is just routing, so a push to `host.docker.internal:/cb:abc` -and a pull of `localhost:/cb:abc` hit the same stored -blob. - -Trade-off: binding to all interfaces puts the registry on every -network interface briefly (~5-10s during prepare). The agent -image we push is built from the repo's public Dockerfile — no -secrets in it — and the user is on their own machine; the LAN -exposure window is short and the contents non-sensitive.""" +The registry is also published on a random host port so smolvm +— a host process — can pull from `localhost:` 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 @@ -58,106 +55,150 @@ 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 -# giving up. Two seconds is empirically enough; bumping to 10s leaves -# headroom for slow CI runners without making the failure mode chatty. +# giving up. Two seconds is empirically enough; 10s leaves headroom +# for slow CI runners without making the failure mode chatty. _READY_TIMEOUT_S = 10.0 @dataclass(frozen=True) -class RegistryEndpoints: - """The two `:` strings to embed in image refs. They - point at the same registry container; only the routing - hostname differs.""" +class RegistryHandle: + """Everything callers need to push to + pull from the ephemeral + registry. - daemon_endpoint: str - host_endpoint: str + `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 `:` form to embed in image + refs given to the crane push container (resolves via docker + network DNS). `pull_endpoint` is the `:` form a + host process (smolvm) uses; the registry's host port mapping + backs this.""" + + network: str + push_endpoint: str + pull_endpoint: str @contextmanager -def ephemeral_registry() -> Iterator[RegistryEndpoints]: - """Bring up a `registry:2.8.3` container on a random host port, - yield the daemon-side + host-side endpoints, force-remove the - container on exit. +def ephemeral_registry() -> Iterator[RegistryHandle]: + """Bring up a per-session docker network + a `registry:2.8.3` + container on it (published on a random host port), yield a + `RegistryHandle`, force-remove both on exit. The container is started with `--rm` so a clean exit cleans up on its own; the `finally` block force-removes on abnormal exit (the calling process crashes between yield and close).""" - name = f"claude-bottle-registry-{uuid.uuid4().hex[:12]}" + session_id = uuid.uuid4().hex[:12] + network = f"claude-bottle-registry-net-{session_id}" + registry_name = f"claude-bottle-registry-{session_id}" + subprocess.run( - [ - "docker", "run", "-d", "--rm", - "--name", name, - # `-p :5000` (no IP prefix) binds the container's port - # 5000 on a random host port across all interfaces. The - # registry container itself listens on 0.0.0.0:5000 - # internally; binding to all interfaces is necessary for - # Docker Desktop's daemon to reach it via - # host.docker.internal — a 127.0.0.1-only host binding - # is invisible to a daemon running in its own VM. - "-p", "5000", - REGISTRY_IMAGE, - ], + ["docker", "network", "create", network], check=True, capture_output=True, ) try: - port = _host_port(name) - _wait_ready(port) - daemon_host = _daemon_side_hostname() - yield RegistryEndpoints( - daemon_endpoint=f"{daemon_host}:{port}", - host_endpoint=f"localhost:{port}", + subprocess.run( + [ + "docker", "run", "-d", "--rm", + "--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:` 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: subprocess.run( - ["docker", "rm", "-f", name], + ["docker", "network", "rm", network], check=False, capture_output=True, ) -def _daemon_side_hostname() -> str: - """Pick the hostname the docker daemon should use to dial the - registry. On Docker Desktop the daemon runs in its own Linux - VM and only sees the host via `host.docker.internal`; on - native Linux the daemon shares the host's network namespace - and `localhost` works. +def crane_push_tarball(handle: RegistryHandle, tarball_path: str, ref: str) -> None: + """Run `crane push --insecure ` 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. - `docker info --format '{{.OperatingSystem}}'` returns - `"Docker Desktop"` on macOS / Windows Desktop installs (and on - Linux Desktop, which also uses a VM). Anything else (e.g. - `"Debian GNU/Linux 12 (bookworm)"`) is a native daemon.""" + 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", "info", "--format", "{{.OperatingSystem}}"], - capture_output=True, - text=True, - check=False, - ) - operating_system = (r.stdout or "").strip() - if operating_system == "Docker Desktop": - return "host.docker.internal" - return "localhost" - - -def _host_port(name: str) -> int: - """Resolve the host-side port docker mapped to the registry's - container port 5000. `docker port 5000/tcp` returns one - or more `host:port` lines (one per address family) — we take - the first IPv4 line.""" - r = subprocess.run( - ["docker", "port", name, "5000/tcp"], + [ + "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"docker port {name} 5000/tcp failed: " + f"crane push of {tarball_path!r} to {ref!r} failed: " + f"{(r.stderr or r.stdout or '').strip() or ''}" + ) + + +def _host_port(name: str) -> int: + """Resolve the host-side port docker mapped to the registry's + container port. `docker port 5000/tcp` returns one or + more `host:port` lines (one per address family) — we take the + first.""" + r = subprocess.run( + ["docker", "port", name, f"{_REGISTRY_CONTAINER_PORT}/tcp"], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + die( + f"docker port {name} {_REGISTRY_CONTAINER_PORT}/tcp failed: " f"{(r.stderr or '').strip() or ''}" ) - # `0.0.0.0:54321\n[::]:54321\n` — take the first line, split - # on the last colon to handle either IPv4 or IPv6 host syntax. + # `0.0.0.0:54321\n[::]:54321\n` — split on the last colon to + # handle either IPv4 or IPv6 host syntax. line = (r.stdout or "").splitlines()[0].strip() _, _, port_str = line.rpartition(":") try: @@ -168,15 +209,15 @@ def _host_port(name: str) -> int: def _wait_ready(port: int) -> None: - """Block until the registry's HTTP layer accepts a TCP connection - on `127.0.0.1:`, or `_READY_TIMEOUT_S` elapses. + """Block until the registry's HTTP layer accepts a TCP + connection on `127.0.0.1:`, or `_READY_TIMEOUT_S` + elapses. A successful TCP connect is sufficient — registry:2.8.3 binds after it's ready to serve `/v2/` requests, so the push that follows will land on a working server. We probe loopback - specifically (not host.docker.internal) because this helper - runs on the host, and 0.0.0.0-bound ports are reachable via - 127.0.0.1 too.""" + specifically (not via the docker network) because this helper + runs on the host.""" deadline = time.monotonic() + _READY_TIMEOUT_S last_err: Exception | None = None while time.monotonic() < deadline: diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index dfeeef5..da70685 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -34,7 +34,7 @@ from ...pipelock import PipelockProxy from ...supervise import Supervise from . import smolvm as _smolvm from .bottle_plan import SmolmachinesBottlePlan -from .local_registry import ephemeral_registry +from .local_registry import crane_push_tarball, ephemeral_registry from .util import smolmachines_bundle_subnet, smolmachines_preflight @@ -185,15 +185,14 @@ def _ensure_smolmachine(image_ref: str) -> Path: it; the sidecar is the actual artifact). Conversion path: `docker build` (the existing layer cache - makes no-change rebuilds cheap) → `docker tag` + `docker push` - using the daemon-side endpoint (`host.docker.internal:` - on Docker Desktop, `localhost:` on native Linux) → - `smolvm pack create --image ` using the - host-side endpoint (always `localhost:` — smolvm is a - host process) → tear down the registry. The two endpoints - route to the same registry container; only the hostname - differs because the docker daemon (on Docker Desktop) doesn't - share the host's loopback. + makes no-change rebuilds cheap) → `docker save` to a tarball + → spin up an ephemeral registry on a private docker network → + `crane push --insecure` from a one-shot container on the same + network → `smolvm pack create --image localhost:/...` + → tear down the registry + network. The crane push detour + sidesteps the Docker-Desktop daemon's HTTPS preference for + non-loopback registries — see the `local_registry` module + 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 @@ -208,10 +207,17 @@ def _ensure_smolmachine(image_ref: str) -> Path: sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine" if sidecar.is_file(): return sidecar - with ephemeral_registry() as endpoints: - push_ref = f"{endpoints.daemon_endpoint}/claude-bottle:{digest}" - pack_ref = f"{endpoints.host_endpoint}/claude-bottle:{digest}" - docker_mod.tag(image_ref, push_ref) - docker_mod.push(push_ref) - _smolvm.pack_create(pack_ref, binary) + tarball = _SMOLMACHINE_CACHE_DIR / f"{digest}.image.tar" + docker_mod.save(image_ref, str(tarball)) + try: + with ephemeral_registry() as handle: + push_ref = f"{handle.push_endpoint}/claude-bottle:{digest}" + 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 diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py index 6a6bc7a..4afe043 100644 --- a/tests/unit/test_docker_util_image.py +++ b/tests/unit/test_docker_util_image.py @@ -54,26 +54,18 @@ class TestImageId(unittest.TestCase): self.assertIn("missing:tag", die.call_args.args[0]) -class TestTagPush(unittest.TestCase): - def test_tag_runs_docker_tag(self): +class TestSave(unittest.TestCase): + def test_save_runs_docker_save(self): with patch.object( docker_mod.subprocess, "run", return_value=_ok(), ) as run: - docker_mod.tag("claude-bottle:latest", "localhost:5000/cb:abc") + docker_mod.save("claude-bottle:latest", "/tmp/img.tar") argv = run.call_args.args[0] self.assertEqual( - ["docker", "tag", "claude-bottle:latest", "localhost:5000/cb:abc"], + ["docker", "save", "claude-bottle:latest", "-o", "/tmp/img.tar"], 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__": unittest.main() diff --git a/tests/unit/test_smolmachines_local_registry.py b/tests/unit/test_smolmachines_local_registry.py index babf5ee..36d534a 100644 --- a/tests/unit/test_smolmachines_local_registry.py +++ b/tests/unit/test_smolmachines_local_registry.py @@ -1,9 +1,10 @@ """Unit: ephemeral local-registry helper (PRD 0023 chunk 4c). -The helper brings up a `registry:2.8.3` container on a random -host port, yields a `(daemon_endpoint, host_endpoint)` pair, and -tears the container down on exit. Tests mock `subprocess.run` + -`socket.create_connection` so they run without docker.""" +The helper brings up a `registry:2.8.3` container on a private +docker network with a random host-side port, yields a +`RegistryHandle`, and tears the container + network down on exit. +Tests mock `subprocess.run` + `socket.create_connection` so they +run without docker.""" from __future__ import annotations @@ -20,71 +21,56 @@ def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: ) -# `docker info` always runs once per ephemeral_registry() to pick -# the daemon-side hostname; the run sequence is therefore -# (docker run, docker port, docker info, docker rm). Helpers below -# build a stock side_effect that covers all four. -def _stock_run_sequence( - *, - port: str = "0.0.0.0:54321\n", - operating_system: str = "Docker Desktop\n", -): +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(stdout="\n"), # docker run - _ok(stdout=port), # docker port - _ok(stdout=operating_system), # docker info - _ok(), # docker rm -f + _ok(), # docker network create + _ok(stdout="\n"), # docker run + _ok(stdout=port_line), # docker port + _ok(), # docker rm -f + _ok(), # docker network rm ] class TestEphemeralRegistry(unittest.TestCase): - def test_yields_endpoints_with_docker_desktop_routing(self): - # On Docker Desktop the daemon runs in its own VM, so the - # registry has to be addressed by host.docker.internal for - # docker push to work; smolvm (host process) still uses - # localhost. + def test_yields_handle_with_network_and_endpoints(self): with patch.object( local_registry.subprocess, "run", - side_effect=_stock_run_sequence(operating_system="Docker Desktop\n"), - ), patch.object( + side_effect=_stock_run_sequence(), + ) as run, patch.object( local_registry.socket, "create_connection", return_value=_FakeSocket(), ): - with local_registry.ephemeral_registry() as endpoints: - self.assertEqual( - "host.docker.internal:54321", endpoints.daemon_endpoint, + with local_registry.ephemeral_registry() as handle: + # push_endpoint points at the registry container by + # its docker-network name on its container port. + self.assertTrue( + handle.push_endpoint.startswith( + "claude-bottle-registry-" + ) ) - self.assertEqual( - "localhost:54321", endpoints.host_endpoint, + 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_yields_endpoints_with_native_linux_routing(self): - # On a native Linux daemon the daemon shares the host's - # network namespace, so localhost reaches the registry from - # both sides. - with patch.object( - local_registry.subprocess, "run", - side_effect=_stock_run_sequence( - operating_system="Debian GNU/Linux 12 (bookworm)\n", - ), - ), patch.object( - local_registry.socket, "create_connection", - return_value=_FakeSocket(), - ): - with local_registry.ephemeral_registry() as endpoints: - self.assertEqual( - "localhost:54321", endpoints.daemon_endpoint, - ) - self.assertEqual( - "localhost:54321", endpoints.host_endpoint, - ) - - def test_runs_docker_with_all_interface_bind(self): - # `-p 5000` (no IP prefix) binds the container's port 5000 - # on a random host port across all interfaces — needed so - # Docker Desktop's daemon can reach the registry via - # host.docker.internal. The 127.0.0.1-only bind we used - # previously was invisible to the daemon's VM. + def test_registry_run_publishes_random_port_across_interfaces(self): with patch.object( local_registry.subprocess, "run", side_effect=_stock_run_sequence(), @@ -94,16 +80,19 @@ class TestEphemeralRegistry(unittest.TestCase): ): with local_registry.ephemeral_registry(): pass - - run_argv = run.call_args_list[0].args[0] + # 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.assertIn("--rm", run_argv) + # `-p 5000` (no IP prefix) — needed so the host-published + # port is reachable from BOTH the host (for smolvm) and the + # docker daemon (for the docker port command to find it). self.assertIn("5000", run_argv) - # Explicitly NOT the loopback-only form — that one's broken - # under Docker Desktop. - self.assertNotIn("127.0.0.1::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_on_clean_exit(self): + def test_force_removes_container_and_network_on_clean_exit(self): with patch.object( local_registry.subprocess, "run", side_effect=_stock_run_sequence(), @@ -114,11 +103,13 @@ class TestEphemeralRegistry(unittest.TestCase): with local_registry.ephemeral_registry(): pass - # Last call is `docker rm -f `. - last_argv = run.call_args_list[-1].args[0] - self.assertEqual(["docker", "rm", "-f"], last_argv[:3]) + # Last two calls are `docker rm -f ` then + # `docker network rm `. + argvs = [c.args[0] for c in run.call_args_list] + self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3]) + self.assertEqual(["docker", "network", "rm"], argvs[-1][:3]) - def test_force_removes_container_on_exception_inside_with(self): + def test_force_removes_on_exception_inside_with(self): with patch.object( local_registry.subprocess, "run", side_effect=_stock_run_sequence(), @@ -130,12 +121,12 @@ class TestEphemeralRegistry(unittest.TestCase): with local_registry.ephemeral_registry(): raise RuntimeError("inside with") - # rm -f still ran on exception. - last_argv = run.call_args_list[-1].args[0] - self.assertEqual(["docker", "rm", "-f"], last_argv[:3]) + # Both teardowns still ran. + argvs = [c.args[0] for c in run.call_args_list] + self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3]) + self.assertEqual(["docker", "network", "rm"], argvs[-1][:3]) - def test_wait_ready_times_out_when_socket_never_connects(self): - # Drop the timeout to a value that fits the test budget. + def test_wait_ready_times_out(self): with patch.object(local_registry, "_READY_TIMEOUT_S", 0.1), patch.object( local_registry.subprocess, "run", side_effect=_stock_run_sequence(), @@ -150,21 +141,26 @@ class TestEphemeralRegistry(unittest.TestCase): with local_registry.ephemeral_registry(): self.fail("yield reached despite unreachable registry") die.assert_called_once() - # rm -f still ran (cleanup goes through the finally block). - last_argv = run.call_args_list[-1].args[0] - self.assertEqual(["docker", "rm", "-f"], last_argv[:3]) + # Teardown still ran via the finally blocks. + argvs = [c.args[0] for c in run.call_args_list] + self.assertEqual(["docker", "rm", "-f"], argvs[-2][:3]) + self.assertEqual(["docker", "network", "rm"], argvs[-1][:3]) - def test_unique_container_name_per_call(self): - names: list[str] = [] + def test_unique_session_ids_per_call(self): + sessions: list[tuple[str, str]] = [] def capture(argv, *a, **kw): + if argv[:3] == ["docker", "network", "create"]: + return _ok() if argv[:2] == ["docker", "run"]: - names.append(argv[argv.index("--name") + 1]) + # `--name ` and `--network ` + # both encode the session id. + name = argv[argv.index("--name") + 1] + 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") - if argv[:2] == ["docker", "info"]: - return _ok(stdout="Docker Desktop\n") return _ok() with patch.object( @@ -178,10 +174,64 @@ class TestEphemeralRegistry(unittest.TestCase): with local_registry.ephemeral_registry(): pass - self.assertEqual(2, len(names)) - self.assertNotEqual(names[0], names[1]) - for n in names: - self.assertTrue(n.startswith("claude-bottle-registry-")) + self.assertEqual(2, len(sessions)) + self.assertNotEqual(sessions[0], sessions[1]) + + +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: diff --git a/tests/unit/test_smolmachines_prepare_image.py b/tests/unit/test_smolmachines_prepare_image.py index afc0804..f1fc64b 100644 --- a/tests/unit/test_smolmachines_prepare_image.py +++ b/tests/unit/test_smolmachines_prepare_image.py @@ -40,40 +40,42 @@ class TestEnsureSmolmachine(unittest.TestCase): _prepare.docker_mod, "image_id", return_value=f"sha256:{digest}fffffffffffffffff", ), patch.object( + _prepare.docker_mod, "save", + ) as save, patch.object( _prepare, "ephemeral_registry", ) as registry, patch.object( - _prepare.docker_mod, "tag", - ) as tag, patch.object( - _prepare.docker_mod, "push", + _prepare, "crane_push_tarball", ) as push, patch.object( _prepare._smolvm, "pack_create", ) as pack: result = _prepare._ensure_smolmachine("claude-bottle:latest") 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() - # No registry, no tag, no push, no pack on cache hit. + # No save (500MB tarball), no registry, no push, no pack on + # cache hit. + save.assert_not_called() registry.assert_not_called() - tag.assert_not_called() push.assert_not_called() pack.assert_not_called() - def test_cache_miss_runs_build_tag_push_pack_in_order(self): + def test_cache_miss_runs_build_save_push_pack_in_order(self): digest = "0123456789abcdef" - # ephemeral_registry yields a RegistryEndpoints with two - # routing hostnames — daemon-side for docker push, - # host-side for smolvm pack create. + # ephemeral_registry yields a RegistryHandle with the + # docker network + a push endpoint (container DNS) and + # pull endpoint (host port-forward). from claude_bottle.backend.smolmachines.local_registry import ( - RegistryEndpoints, + RegistryHandle, ) class _Reg: def __enter__(self_inner): - return RegistryEndpoints( - daemon_endpoint="host.docker.internal:54321", - host_endpoint="localhost:54321", + return RegistryHandle( + network="cb-net-xyz", + push_endpoint="cb-registry-xyz:5000", + pull_endpoint="localhost:54321", ) def __exit__(self_inner, *exc): return False @@ -92,13 +94,13 @@ class TestEnsureSmolmachine(unittest.TestCase): _prepare.docker_mod, "image_id", return_value=f"sha256:{digest}fffffffffffffffff", ), patch.object( + _prepare.docker_mod, "save", + side_effect=record("save"), + ) as save, patch.object( _prepare, "ephemeral_registry", return_value=_Reg(), ), patch.object( - _prepare.docker_mod, "tag", - side_effect=record("tag"), - ) as tag, patch.object( - _prepare.docker_mod, "push", + _prepare, "crane_push_tarball", side_effect=record("push"), ) as push, patch.object( _prepare._smolvm, "pack_create", @@ -106,26 +108,27 @@ class TestEnsureSmolmachine(unittest.TestCase): ) as pack: _prepare._ensure_smolmachine("claude-bottle:latest") - # build first (no point pushing if the build fails), then - # tag → push → pack against the registry endpoints. - self.assertEqual(["build", "tag", "push", "pack"], calls) + # Build → save → push → pack in that order. No `docker + # push` (the daemon's HTTPS-by-default path is what we're + # sidestepping). + self.assertEqual(["build", "save", "push", "pack"], calls) - # tag + push target the daemon-side endpoint (host.docker - # .internal on Docker Desktop, since the daemon's - # localhost is its own VM's loopback). - tag_args = tag.call_args.args - self.assertEqual("claude-bottle:latest", tag_args[0]) - self.assertEqual( - f"host.docker.internal:54321/claude-bottle:{digest}", tag_args[1], - ) + # docker save targets a per-digest tarball alongside the + # cached sidecar. + save_args = save.call_args.args + self.assertEqual("claude-bottle:latest", save_args[0]) + self.assertTrue(save_args[1].endswith(f"{digest}.image.tar")) + + # crane push runs against the push_endpoint (container DNS + # on the registry network) with the digest as the tag. push_args = push.call_args.args self.assertEqual( - f"host.docker.internal:54321/claude-bottle:{digest}", push_args[0], + f"cb-registry-xyz:5000/claude-bottle:{digest}", push_args[2], ) - # pack_create reads from the host-side endpoint (smolvm is - # a host process and can only resolve localhost). The - # registry stores images by repo+tag, so both endpoints - # hit the same blob. + + # 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 self.assertEqual( f"localhost:54321/claude-bottle:{digest}", pack_args[0], From 906c9fd1bbc564b52b5c806de7e980e22af6f0d3 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 14:55:08 -0400 Subject: [PATCH 03/13] fix(smolmachines): preflight print uses plan-level egress routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SmolmachinesBottlePlan.print` iterated over `bottle.egress.routes` (the manifest's capitalized-attribute form on `manifest.EgressRoute`) but accessed `r.host` (lowercase). Worked when no egress routes were declared; AttributeError ("EgressRoute has no attribute 'host'") on the first bottle with a route. Switch to `self.egress_plan.routes` — the resolved plan-level EgressRoute (lowercase `host`), same source the docker backend's print uses. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/smolmachines/bottle_plan.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/claude_bottle/backend/smolmachines/bottle_plan.py b/claude_bottle/backend/smolmachines/bottle_plan.py index 3bffb6d..801be03 100644 --- a/claude_bottle/backend/smolmachines/bottle_plan.py +++ b/claude_bottle/backend/smolmachines/bottle_plan.py @@ -89,7 +89,10 @@ class SmolmachinesBottlePlan(BottlePlan): upstreams = [ f"{g.Name} → {g.Upstream}" for g in bottle.git ] - routes = [r.host for r in bottle.egress.routes] + # Use the resolved egress_plan (lowercase `host` on the + # 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) info(f"agent : {spec.agent_name}") From e26d459a975aae3c55bd782f85b868ad0e5b6131 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 14:58:34 -0400 Subject: [PATCH 04/13] fix(smolmachines): run claude + shell exec as the node user `smolvm machine exec` runs commands as root in the VM, but the agent image's USER is `node`. claude-code refuses `--dangerously-skip-permissions` when invoked as root, killing the interactive session right after `attaching interactive claude session...`: --dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons Wrap both `exec_claude` and `exec(script)` in `runuser -l node -c ...` so commands run as the node user with node's $HOME / $USER (login shell). The docker backend gets this behavior for free via the image's USER directive; this restores parity. shlex-quote each claude argv element when stitching the runuser -c shell command so paths / flags with shell-special chars survive the parse. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/smolmachines/bottle.py | 44 ++++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/claude_bottle/backend/smolmachines/bottle.py b/claude_bottle/backend/smolmachines/bottle.py index efa0aa1..8a5dbdd 100644 --- a/claude_bottle/backend/smolmachines/bottle.py +++ b/claude_bottle/backend/smolmachines/bottle.py @@ -4,12 +4,20 @@ Routes `exec_claude` / `exec` / `cp_in` through `smolvm machine exec` / `smolvm machine cp`. The handle is yielded by `launch` and torn down via the surrounding ExitStack on context exit; `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` wrap commands in `runuser -l node -c` +so they execute as the node user (and pick up node's $HOME / +$USER from the login shell) — matches the docker backend's +default-USER behavior.""" from __future__ import annotations +import shlex import subprocess -import sys from .. import Bottle, ExecResult from . import smolvm as _smolvm @@ -29,33 +37,43 @@ class SmolmachinesBottle(Bottle): self._prompt_path = prompt_path def exec_claude(self, argv: list[str], *, tty: bool = True) -> int: - """Run `claude` interactively inside the VM. Inherits the - operator's terminal (stdin / stdout / stderr) so the - session feels native. Blocks until claude exits; returns - the in-VM exit code. + """Run `claude` interactively inside the VM as the `node` + user. Inherits the operator's terminal (stdin / stdout / + stderr) so the session feels native. Blocks until claude + exits; returns the in-VM exit code. We bypass the captured-output `machine_exec` helper here because that one wraps stdout/stderr in pipes — fine for scripted exec, wrong for an interactive shell. Drop down - to `subprocess.run` with the TTY inherited.""" + to `subprocess.run` with the TTY inherited. + + `runuser -l node -c` runs the inner command under node's + login shell so $HOME / $USER are set. Without that switch + claude bails on `--dangerously-skip-permissions cannot be + used with root/sudo privileges`.""" flags = ["smolvm", "machine", "exec", "--name", self.name] if tty: flags += ["-i", "-t"] claude_argv = ["claude"] if self._prompt_path: claude_argv += ["--append-system-prompt-file", self._prompt_path] - flags += ["--", *claude_argv, *argv] + claude_argv += argv + # shlex-quote each piece so flags / paths with shell-special + # chars survive the runuser -c shell parse. + inner = " ".join(shlex.quote(p) for p in claude_argv) + flags += ["--", "runuser", "-l", "node", "-c", f"exec {inner}"] result = subprocess.run(flags, check=False) return result.returncode def exec(self, script: str) -> ExecResult: - """Run a POSIX shell script and capture the result. The - script runs under `/bin/sh -c`, matching what the docker - backend's `exec` does — callers can write shell-y test - helpers without worrying about argv splitting.""" + """Run a POSIX shell script as the `node` user and capture + the result. Matches the docker backend's `exec`, which + defaults to the image's USER (also node) — so test + helpers / provision shell-outs run with the same identity + on both backends.""" r = _smolvm.machine_exec( self.name, - ["/bin/sh", "-c", script], + ["runuser", "-l", "node", "-c", script], ) return ExecResult( returncode=r.returncode, From af65c10361b49e679b8f661e7fefc86d20b4c01c Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 27 May 2026 15:00:13 -0400 Subject: [PATCH 05/13] refactor: Bottle.exec takes a user= kwarg, default node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the user-switch from a hardcoded `node` to a keyword arg so callers can opt into root (or any other user) when needed. Default stays `node` — matches the docker image's USER and the smolmachines runuser default. Lifts the change through the base ABC, docker, and smolmachines backends: - Base: `def exec(self, script, *, user="node")`. - Docker: adds `-u ` to `docker exec` (no-op when user is node, the image's default). - Smolmachines: `runuser -l -c