feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c) #71

Merged
didericis merged 1 commits from prd-0023-chunk-4c-agent-image into main 2026-05-27 14:05:39 -04:00
Collaborator

Summary

  • smolvm pack create only takes OCI registry refs (tested: rejects docker-daemon://, oci-layout://, docker-archive: tarballs, and every other transport — crane treats anything with a scheme prefix as a registry hostname).
  • To convert the repo Dockerfile into a .smolmachine we now bring up an ephemeral registry:2.8.3 on 127.0.0.1:<random>, docker tag + docker push into it, run smolvm pack create --image localhost:<port>/..., and tear down the registry. Loopback-only bind, no LAN exposure.
  • .smolmachine artifacts are 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 re-runs the pipeline; unchanged rebuilds skip it entirely.
  • Adds thin docker tag / docker push / docker image inspect helpers in backend/docker/util.py. Adds backend/smolmachines/local_registry.py for the ephemeral-registry context manager (pinned registry:2.8.3 by digest).

PRD 0023 is updated with the registry-hop rationale.

Chunk 4d (provision_ca + provision_git + provision_supervise) is the follow-up — those need this agent image to be in place so update-ca-certificates, git, and claude mcp add exist inside the guest.

## Summary - `smolvm pack create` only takes OCI registry refs (tested: rejects `docker-daemon://`, `oci-layout://`, `docker-archive:` tarballs, and every other transport — crane treats anything with a scheme prefix as a registry hostname). - To convert the repo Dockerfile into a `.smolmachine` we now bring up an ephemeral `registry:2.8.3` on `127.0.0.1:<random>`, `docker tag` + `docker push` into it, run `smolvm pack create --image localhost:<port>/...`, and tear down the registry. Loopback-only bind, no LAN exposure. - `.smolmachine` artifacts are 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 re-runs the pipeline; unchanged rebuilds skip it entirely. - Adds thin `docker tag` / `docker push` / `docker image inspect` helpers in `backend/docker/util.py`. Adds `backend/smolmachines/local_registry.py` for the ephemeral-registry context manager (pinned registry:2.8.3 by digest). PRD 0023 is updated with the registry-hop rationale. Chunk 4d (`provision_ca` + `provision_git` + `provision_supervise`) is the follow-up — those need this agent image to be in place so `update-ca-certificates`, `git`, and `claude mcp add` exist inside the guest.
didericis-claude added 1 commit 2026-05-27 13:51:22 -04:00
feat(smolmachines): build agent image from repo Dockerfile (PRD 0023 chunk 4c)
test / unit (pull_request) Successful in 21s
test / unit (push) Successful in 21s
test / integration (push) Successful in 42s
test / integration (pull_request) Successful in 41s
1fa17d1822
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>
didericis reviewed 2026-05-27 13:56:30 -04:00
@@ -0,0 +30,4 @@
# pipelock image pin in claude_bottle/backend/docker/pipelock.py.
REGISTRY_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_REGISTRY_IMAGE",
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
Owner

How much attack surface does the env var and this registry download introduce (and the local registry step in general)

How much attack surface does the env var and this registry download introduce (and the local registry step in general)
didericis approved these changes 2026-05-27 14:00:46 -04:00
didericis-claude reviewed 2026-05-27 14:03:30 -04:00
@@ -0,0 +30,4 @@
# pipelock image pin in claude_bottle/backend/docker/pipelock.py.
REGISTRY_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_REGISTRY_IMAGE",
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
Author
Collaborator

Small, mostly equivalent to the docker dependencies the project already has.

The pulled imageregistry:2.8.3 pinned by digest (sha256:a3d8aaa6...). Distribution's open-source registry from Docker Inc., the upstream of every docker daemon's docker push; CVE history is mostly DoS, no RCE. Pinning means the bytes are immutable — once the digest is in the source tree, every machine that runs this verifies against it. Same trust model as the ghcr.io/luckypipewrench/pipelock@sha256:3b1a3941... pin in backend/docker/pipelock.py.

The running registry container — bound to 127.0.0.1::5000 (loopback only, no LAN exposure), lives ~5-10s during one prepare call, force-removed in finally. While alive it's reachable by other processes on the host running as the same uid — but those processes are already past whatever sandbox the project provides (this is host-side bringup, before the bottle exists). They could read the pushed image bytes, but those bytes are the public Dockerfile-built image — no secrets baked in.

The env var override — same pattern as CLAUDE_BOTTLE_PIPELOCK_IMAGE, CLAUDE_BOTTLE_EGRESS_PORT, etc. An attacker who can set arbitrary env vars in the shell that runs cli.py can already substitute PATH, DOCKER_HOST, LD_PRELOAD, etc. — way nastier vectors than swapping the registry image. It exists for developer override (testing against a different registry version); shouldn't appear in production wrapping scripts.

What it doesn't add — no new network surface (loopback only), no new persistent state (container is --rm), no secrets in flight (the pushed image is just the agent Dockerfile output), no new privileged ops (registry runs as its registry user inside its container).

The one thing worth flagging: this puts a docker dependency in the smolmachines prepare path that wasn't there before (the docker backend ships with docker as a hard requirement; smolmachines previously only needed it for the sidecar bundle, now also for the agent-image build). Worth a README note for the v1 macOS-only scope.

Small, mostly equivalent to the docker dependencies the project already has. **The pulled image** — `registry:2.8.3` pinned by digest (`sha256:a3d8aaa6...`). Distribution's open-source registry from Docker Inc., the upstream of every docker daemon's `docker push`; CVE history is mostly DoS, no RCE. Pinning means the bytes are immutable — once the digest is in the source tree, every machine that runs this verifies against it. Same trust model as the `ghcr.io/luckypipewrench/pipelock@sha256:3b1a3941...` pin in `backend/docker/pipelock.py`. **The running registry container** — bound to `127.0.0.1::5000` (loopback only, no LAN exposure), lives ~5-10s during one `prepare` call, force-removed in `finally`. While alive it's reachable by other processes on the host running as the same uid — but those processes are already past whatever sandbox the project provides (this is host-side bringup, before the bottle exists). They could read the pushed image bytes, but those bytes are the public Dockerfile-built image — no secrets baked in. **The env var override** — same pattern as `CLAUDE_BOTTLE_PIPELOCK_IMAGE`, `CLAUDE_BOTTLE_EGRESS_PORT`, etc. An attacker who can set arbitrary env vars in the shell that runs `cli.py` can already substitute `PATH`, `DOCKER_HOST`, `LD_PRELOAD`, etc. — way nastier vectors than swapping the registry image. It exists for developer override (testing against a different registry version); shouldn't appear in production wrapping scripts. **What it doesn't add** — no new network surface (loopback only), no new persistent state (container is `--rm`), no secrets in flight (the pushed image is just the agent Dockerfile output), no new privileged ops (registry runs as its `registry` user inside its container). The one thing worth flagging: this puts a docker dependency in the smolmachines prepare path that wasn't there before (the docker backend ships with `docker` as a hard requirement; smolmachines previously only needed it for the sidecar bundle, now also for the agent-image build). Worth a README note for the v1 macOS-only scope.
didericis marked this conversation as resolved
didericis merged commit 1fa17d1822 into main 2026-05-27 14:05:39 -04:00
didericis deleted branch prd-0023-chunk-4c-agent-image 2026-05-27 14:05:40 -04:00
Sign in to join this conversation.