fix(smolmachines): docker push fails on Docker Desktop — daemon-side route differs from host loopback
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 42s

`./cli.py start <agent>` under CLAUDE_BOTTLE_BACKEND=smolmachines
died at `docker push localhost:<port>/claude-bottle:<id>` with
`Get "http://localhost:<port>/v2/": context deadline exceeded`.

Cause: chunk 4c bound the ephemeral registry to `127.0.0.1::5000`
and used `localhost:<port>` 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:<port>` on Docker
    Desktop (daemon-side hostname for the host VM gateway),
    `localhost:<port>` on a native Linux daemon that shares the
    host's network namespace. Used for `docker tag` + `docker
    push`.
  - `host_endpoint` — always `localhost:<port>`. 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:<port>/cb:<id>` and a pull from
`localhost:<port>/cb:<id>` 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:41:26 -04:00
parent ac8c7ba696
commit f4026ea3ae
4 changed files with 221 additions and 70 deletions
@@ -5,13 +5,36 @@ conversion path (PRD 0023 chunk 4c).
can't read the local docker daemon's image cache, an OCI layout can't read the local docker daemon's image cache, an OCI layout
directory, or a `docker save` tarball. To convert the agent's directory, or a `docker save` tarball. To convert the agent's
Dockerfile-built image into a `.smolmachine` artifact we run a Dockerfile-built image into a `.smolmachine` artifact we run a
short-lived `registry:2.8.3` container on `127.0.0.1:<random>`, short-lived `registry:2.8.3` container, push the locally-tagged
push the locally-tagged image into it, and let smolvm pull from image into it, and let smolvm pull from there. The registry
there. The registry container is torn down as soon as the pack container is torn down as soon as the pack completes.
completes.
Loopback-only bind + the host's docker layer cache mean the round Two routing hostnames, one registry container. On Docker Desktop
trip is fast (~5s) and there's no exposed surface on the LAN.""" (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::<port>` 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:<port>` on Docker Desktop,
`localhost:<port>` 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:<port>` — 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:<port>/cb:abc`
and a pull of `localhost:<port>/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 from __future__ import annotations
@@ -21,6 +44,7 @@ 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
@@ -40,10 +64,21 @@ REGISTRY_IMAGE = os.environ.get(
_READY_TIMEOUT_S = 10.0 _READY_TIMEOUT_S = 10.0
@dataclass(frozen=True)
class RegistryEndpoints:
"""The two `<host>:<port>` 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 @contextmanager
def ephemeral_registry() -> Iterator[int]: def ephemeral_registry() -> Iterator[RegistryEndpoints]:
"""Bring up a `registry:2.8.3` container on a random loopback """Bring up a `registry:2.8.3` container on a random host port,
port, yield the port, force-remove the container on exit. 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 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
@@ -53,10 +88,14 @@ def ephemeral_registry() -> Iterator[int]:
[ [
"docker", "run", "-d", "--rm", "docker", "run", "-d", "--rm",
"--name", name, "--name", name,
# `127.0.0.1::5000` = bind to loopback, pick a random host # `-p :5000` (no IP prefix) binds the container's port
# port. No LAN exposure; the container hangs around just # 5000 on a random host port across all interfaces. The
# long enough for one push + one pack-create. # registry container itself listens on 0.0.0.0:5000
"-p", "127.0.0.1::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, REGISTRY_IMAGE,
], ],
check=True, check=True,
@@ -65,7 +104,11 @@ def ephemeral_registry() -> Iterator[int]:
try: try:
port = _host_port(name) port = _host_port(name)
_wait_ready(port) _wait_ready(port)
yield port daemon_host = _daemon_side_hostname()
yield RegistryEndpoints(
daemon_endpoint=f"{daemon_host}:{port}",
host_endpoint=f"localhost:{port}",
)
finally: finally:
subprocess.run( subprocess.run(
["docker", "rm", "-f", name], ["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: 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 5000. `docker port <name> 5000/tcp` returns one or container port 5000. `docker port <name> 5000/tcp` returns one
more `host:port` lines; the loopback-only -p binding ensures we or more `host:port` lines (one per address family) — we take
get exactly `127.0.0.1:<port>`.""" the first IPv4 line."""
r = subprocess.run( r = subprocess.run(
["docker", "port", name, "5000/tcp"], ["docker", "port", name, "5000/tcp"],
capture_output=True, capture_output=True,
@@ -90,8 +156,8 @@ def _host_port(name: str) -> int:
f"docker port {name} 5000/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>'}"
) )
# `127.0.0.1:54321\n` — split on the last colon to handle the # `0.0.0.0:54321\n[::]:54321\n` — take the first line, split
# `host:port` shape without parsing IP literals. # on the last colon to handle either IPv4 or IPv6 host syntax.
line = (r.stdout or "").splitlines()[0].strip() line = (r.stdout or "").splitlines()[0].strip()
_, _, port_str = line.rpartition(":") _, _, port_str = line.rpartition(":")
try: try:
@@ -107,7 +173,10 @@ def _wait_ready(port: int) -> None:
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.""" 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 deadline = time.monotonic() + _READY_TIMEOUT_S
last_err: Exception | None = None last_err: Exception | None = None
while time.monotonic() < deadline: while time.monotonic() < deadline:
+20 -13
View File
@@ -184,14 +184,20 @@ 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 makes Conversion path: `docker build` (the existing layer cache
no-change rebuilds cheap) → `docker tag` with a makes no-change rebuilds cheap) → `docker tag` + `docker push`
`localhost:<port>/...` ref → bring up the ephemeral registry using the daemon-side endpoint (`host.docker.internal:<port>`
container → `docker push` into it → `smolvm pack create --image on Docker Desktop, `localhost:<port>` on native Linux) →
<localhost ref>` → tear down the registry. Each pack-create `smolvm pack create --image <host endpoint>` using the
costs several seconds even on a hot cache, so we skip the whole host-side endpoint (always `localhost:<port>` — smolvm is a
pipeline when the cached sidecar is already on disk for this host process) → tear down the registry. The two endpoints
image ID.""" 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) _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
@@ -202,9 +208,10 @@ 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
with ephemeral_registry() as port: with ephemeral_registry() as endpoints:
local_ref = f"localhost:{port}/claude-bottle:{digest}" push_ref = f"{endpoints.daemon_endpoint}/claude-bottle:{digest}"
docker_mod.tag(image_ref, local_ref) pack_ref = f"{endpoints.host_endpoint}/claude-bottle:{digest}"
docker_mod.push(local_ref) docker_mod.tag(image_ref, push_ref)
_smolvm.pack_create(local_ref, binary) docker_mod.push(push_ref)
_smolvm.pack_create(pack_ref, binary)
return sidecar return sidecar
+84 -25
View File
@@ -1,15 +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 random The helper brings up a `registry:2.8.3` container on a random
loopback port, yields the port, and tears the container down on host port, yields a `(daemon_endpoint, host_endpoint)` pair, and
exit. Tests mock `subprocess.run` + `socket.create_connection` so tears the container down on exit. Tests mock `subprocess.run` +
they run without docker.""" `socket.create_connection` so they run without docker."""
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import unittest import unittest
from unittest.mock import call, patch from unittest.mock import patch
from claude_bottle.backend.smolmachines import local_registry 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="<container-id>\n"), # docker run
_ok(stdout=port), # docker port
_ok(stdout=operating_system), # docker info
_ok(), # docker rm -f
]
class TestEphemeralRegistry(unittest.TestCase): class TestEphemeralRegistry(unittest.TestCase):
def test_yields_host_port_parsed_from_docker_port(self): def test_yields_endpoints_with_docker_desktop_routing(self):
# docker run + docker port + docker rm in that order; the # On Docker Desktop the daemon runs in its own VM, so the
# port command returns `127.0.0.1:54321` for the loopback # registry has to be addressed by host.docker.internal for
# binding. # docker push to work; smolvm (host process) still uses
# localhost.
with patch.object( with patch.object(
local_registry.subprocess, "run", local_registry.subprocess, "run",
side_effect=[ side_effect=_stock_run_sequence(operating_system="Docker Desktop\n"),
_ok(stdout="<container-id>\n"), ), patch.object(
_ok(stdout="127.0.0.1:54321\n"), local_registry.socket, "create_connection",
_ok(), 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( ) 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 port: with local_registry.ephemeral_registry():
self.assertEqual(54321, port) pass
# docker run, docker port, docker rm -f
self.assertEqual(3, run.call_count)
run_argv = run.call_args_list[0].args[0] run_argv = run.call_args_list[0].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)
# Loopback-only port binding so the registry isn't exposed self.assertIn("5000", run_argv)
# on the LAN even briefly. # Explicitly NOT the loopback-only form — that one's broken
self.assertIn("127.0.0.1::5000", run_argv) # under Docker Desktop.
self.assertNotIn("127.0.0.1::5000", run_argv)
def test_force_removes_container_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=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], side_effect=_stock_run_sequence(),
) as run, patch.object( ) as run, patch.object(
local_registry.socket, "create_connection", local_registry.socket, "create_connection",
return_value=_FakeSocket(), return_value=_FakeSocket(),
@@ -66,7 +121,7 @@ class TestEphemeralRegistry(unittest.TestCase):
def test_force_removes_container_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=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], side_effect=_stock_run_sequence(),
) as run, patch.object( ) as run, patch.object(
local_registry.socket, "create_connection", local_registry.socket, "create_connection",
return_value=_FakeSocket(), return_value=_FakeSocket(),
@@ -83,7 +138,7 @@ class TestEphemeralRegistry(unittest.TestCase):
# Drop the timeout to a value that fits the test budget. # 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=[_ok(stdout="cid\n"), _ok(stdout="127.0.0.1:1234\n"), _ok()], side_effect=_stock_run_sequence(),
) 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"),
@@ -105,8 +160,12 @@ class TestEphemeralRegistry(unittest.TestCase):
def capture(argv, *a, **kw): def capture(argv, *a, **kw):
if argv[:2] == ["docker", "run"]: if argv[:2] == ["docker", "run"]:
names.append(argv[argv.index("--name") + 1]) names.append(argv[argv.index("--name") + 1])
return _ok(stdout="cid\n" if argv[:2] == ["docker", "run"] return _ok(stdout="cid\n")
else "127.0.0.1:1\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( with patch.object(
local_registry.subprocess, "run", side_effect=capture, local_registry.subprocess, "run", side_effect=capture,
+28 -12
View File
@@ -62,10 +62,19 @@ class TestEnsureSmolmachine(unittest.TestCase):
def test_cache_miss_runs_build_tag_push_pack_in_order(self): def test_cache_miss_runs_build_tag_push_pack_in_order(self):
digest = "0123456789abcdef" 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: class _Reg:
def __enter__(self_inner): def __enter__(self_inner):
return 54321 return RegistryEndpoints(
daemon_endpoint="host.docker.internal:54321",
host_endpoint="localhost:54321",
)
def __exit__(self_inner, *exc): def __exit__(self_inner, *exc):
return False return False
@@ -98,22 +107,29 @@ class TestEnsureSmolmachine(unittest.TestCase):
_prepare._ensure_smolmachine("claude-bottle:latest") _prepare._ensure_smolmachine("claude-bottle:latest")
# build first (no point pushing if the build fails), then # 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) self.assertEqual(["build", "tag", "push", "pack"], calls)
# tag goes from the source ref to a localhost:<port> ref # tag + push target the daemon-side endpoint (host.docker
# with the digest as the tag suffix (so different builds # .internal on Docker Desktop, since the daemon's
# land on different tags in the registry). # localhost is its own VM's loopback).
tag_args = tag.call_args.args tag_args = tag.call_args.args
self.assertEqual("claude-bottle:latest", tag_args[0]) self.assertEqual("claude-bottle:latest", tag_args[0])
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", tag_args[1]) self.assertEqual(
# push targets the same localhost ref tag picks. f"host.docker.internal:54321/claude-bottle:{digest}", tag_args[1],
)
push_args = push.call_args.args push_args = push.call_args.args
self.assertEqual(f"localhost:54321/claude-bottle:{digest}", push_args[0]) self.assertEqual(
# pack_create reads from the registry ref, writes the f"host.docker.internal:54321/claude-bottle:{digest}", push_args[0],
# binary alongside the cached sidecar. )
# 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 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")) self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))