The previous fix (`host.docker.internal:<port>` 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
<registry-container>:5000/...`. Container DNS resolves the
registry on the network; `--insecure` forces plain HTTP.
5. `smolvm pack create --image localhost:<host port>/...` 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 <noreply@anthropic.com>
Replaces the alpine:latest placeholder with a real claude-bottle
agent image, converted into a .smolmachine artifact via an
ephemeral local OCI registry.
Why the registry hop: smolvm pack create only accepts OCI registry
refs. Empirically it rejects docker-daemon://, oci-layout://,
docker-archive: tarballs, and every other transport tested — the
crane backend treats anything with a scheme prefix as a registry
hostname. To convert a locally-built docker image into a
.smolmachine we have to push it somewhere smolvm can pull from.
Smallest path: bring up registry:2.8.3 bound to 127.0.0.1:<random>,
docker tag + docker push into it, smolvm pack create --image
localhost:<port>/claude-bottle:<id>, tear down the registry.
The .smolmachine is cached under
~/.cache/claude-bottle/smolmachines/ keyed by the docker image ID
(first 16 hex chars of the sha256), so a Dockerfile change picks
up a new image ID and invalidates the cache. Unchanged rebuilds
skip the whole build → registry → pack pipeline.
This puts `docker build` in smolmachines prepare (the docker
backend defers it to launch). Necessary because pack_create needs
the image ID to derive the cache key, and prepare is the only
hook ahead of launch that runs once per slug.
Adds:
- claude_bottle/backend/docker/util.py: image_id / tag / push
helpers (thin docker CLI wrappers).
- claude_bottle/backend/smolmachines/local_registry.py:
ephemeral_registry() context manager; pins registry:2.8.3 by
digest, binds 127.0.0.1::5000 (loopback-only), force-removes on
exit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>