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"))