Skip to content

Per-provider agent-tools OCI images, built from sha256-pinned upstream artifacts and injected read-only at /opt/lab, carry the agent surface into any user-chosen dev container — a delivery vehicle, not a revisit of lab's nix-first packaging

Container isolation (issue #202) runs a session inside a rootless podman container whose base image the operator chooses — any image, not one lab builds. That reopens a question ADR-0033 answered only for the host PATH: how do the provider CLI and labctl reach a session when lab does not own the base image? ADR-0033 put agent CLIs and the tools baseline on the lab unit PATH, inherited through the re-adopted tmux server (ADR-0002's KillMode=process). That mechanism assumes lab owns the PATH the session runs under; inside an arbitrary OCI image it owns nothing. Issue #203 (2026-07-22) ships the delivery vehicle: per-provider agent-tools OCI images, each a self-contained bundle of one agent CLI plus a static labctl, mounted read-only into the operator's image at run time so the agent surface travels with lab. The container runner (#205) is the consumer; this ADR pins the artifact, its contents, and the injection contract it reads.

The pins, decided:

  • One image per provider, FROM scratch, carrying only lab-owned binaries. Tag git.cloonar.com/cloonar/agent-tools:<provider>-<cli-version>; claude and codex ship today, gemini is deferred behind the adapter (#126). The claude image's whole root filesystem is /bin/claude (Claude Code's linux-x64-musl native build), /bin/labctl (built static from this repo), and /lib/ld-musl-x86_64.so.1 (the musl loader, taken from Alpine). The codex image is /bin/codex (upstream static-pie musl binary) and /bin/labctl — no loader needed. Nothing else: no shell, no userland, no base. These images are pure payload and are never run as containers.

  • The injection contract is a read-only image mount at /opt/lab, and /opt/lab is a HARD requirement. The runner mounts the digest-pinned image into the operator's chosen dev container via podman run --mount type=image,src=…@sha256:…,dst=/opt/lab and prepends /opt/lab/bin to PATH, so the binaries resolve at /opt/lab/bin/claude etc. The destination is not free to change: the claude binary's ELF interpreter (PT_INTERP) is rewritten at image-build time to the absolute path /opt/lab/lib/ld-musl-x86_64.so.1. Mount the image anywhere else and claude will not start. This baked-in path is the price of running without the base image's libc; it is a contract on the runner, not a suggestion.

  • The libc mechanism — musl build + one bundled loader + PT_INTERP rewrite — is why this works on both a glibc (debian) and a musl (alpine) base without touching either's libc. Claude Code's musl native build is a bun-compiled executable whose only external dependency is the musl loader (DT_NEEDED: libc.musl-x86_64.so.1 — in musl the loader IS libc). Ship that one file inside the image, point PT_INTERP at it, and neither base's libc is ever consulted. codex's linux musl build is static-pie with no PT_INTERP and no DT_NEEDED, so it runs as-is; labctl is pure Go built CGO_ENABLED=0, statically linked with no loader reference.

  • PT_INTERP patching, not an explicit-loader wrapper — the embedded-ripgrep self-exec is the constraint that forced it, and it is load-bearing for future changes. The obvious alternative, a wrapper that execs /opt/lab/lib/ld-musl… /opt/lab/libexec/claude, breaks claude: the binary re-execs process.execPath under argv0=rg to serve its EMBEDDED ripgrep, and under an explicit loader process.execPath would be the loader, not claude — ripgrep gone. Patching PT_INTERP leaves the binary self-identifying, so self-exec stays intact (verified empirically: the patched binary answers --version and still serves the embedded ripgrep). Any future change to how claude is packaged must preserve self-exec.

  • containers/agent-tools/versions.env is the single source of truth for CLI versions and artifact checksums, and it is coupled to the compat records. It pins CLAUDE_CODE_VERSION=2.1.198 + the sha256 of the linux-x64-musl binary (from Anthropic's per-version manifest.json at downloads.claude.ai/claude-code-releases/<ver>/manifest.json) and CODEX_VERSION=0.133.0 + the sha256 of codex-x86_64-unknown-linux-musl.tar.gz (from the GitHub release's per-asset digest). These versions ARE the repo's compat pins — internal/compat/compat.md (Claude Code 2.1.198) and internal/compat/codex/compat.md (codex-cli 0.133.0) — which until now lived only as prose; versions.env makes them build inputs. Bump procedure: re-verify the compat record against the new CLI version FIRST (the compat doc is the checklist), then bump the version + sha in versions.env; the PR runs the injection smoke test and the merge to main publishes.

  • Tags are human-facing; the digest is the pinning contract. <provider>-<cli-version> is a moving reference — a same-tag re-push (e.g. a labctl refresh at the same CLI version) produces a new digest under the same tag. Consumers (#205) pin agent-tools@sha256:…, which the release job emits to the job summary. CI has two deliberately asymmetric legs (.forgejo/workflows/agent-tools.yml): a PR leg that builds locally and runs the injection smoke test (mount into stock debian:stable-slim and alpine, run claude --version / codex --version / labctl --help) touching no secret and never pushing, and a release leg (push to main path-gated on containers/** + the workflow, plus workflow_dispatch for a labctl-only refresh) that re-runs the smoke test then pushes. The publish job fails loud with provisioning instructions when FORGE_REGISTRY_TOKEN is absent.

  • The injected image deliberately ships ONLY lab-owned binaries — the ADR-0033 userland baseline is the dev image's own business. ADR-0033 pins a fixed tools baseline (tar, curl, jq, rg, file, patch, ps, …) on the lab unit PATH. That baseline does NOT move into this image. Any-image injection must not impose a userland on the operator's chosen container — the dev image brings its own tools, lab brings only the agent CLI and labctl. Overlapping the baseline here would make the mount opinionated about the base and defeat the any-image goal.

  • This does not revisit ADR-0002; it fills the seam ADR-0002 left open. ADR-0002 pinned "no Docker in the MVP; an OCI image is explicit roadmap" for lab's OWN packaging — and that stands: lab stays nix-first, the flake is still the primary artifact, and lab/labctl/web are still built by nix. The OCI image here is not lab-in-a-container; it is a delivery vehicle for agent tools INTO user-chosen dev containers, the roadmap seam ADR-0002 named. Nix-first packaging of lab and an OCI carrier for the agent surface are orthogonal — this is not a contradiction of ADR-0002.

Status

Accepted. Settled via issue #203 (2026-07-22). Carries ADR-0033's agent-CLI delivery idea across the container-isolation boundary (#202): where ADR-0033 puts the CLI on the host unit PATH for a session that shares lab's PATH, this ADR mounts the CLI into a session that runs inside an operator-chosen image and owns its own PATH — and deliberately does NOT carry ADR-0033's userland baseline along (the dev image owns its userland). Fills the "OCI image is explicit roadmap" seam ADR-0002 left open without revisiting ADR-0002's nix-first packaging of lab itself. The versions.env pins are the same values internal/compat/compat.md and internal/compat/codex/compat.md record; the compat record stays the authority and versions.env is its build-input projection. The container runner (#205) is the consumer that reads the /opt/lab mount and digest-pinned reference. x86_64-only for now.

Considered options

  • A glibc claude build behind an explicit-loader wrapper. Rejected twice over. The glibc linux-x64 build needs the full glibc family (ld-linux + libc + libm + libpthread + libdl + librt) bundled, where the musl build needs exactly one loader file. And a wrapper that invokes an explicit loader breaks claude's embedded-ripgrep self-exec: process.execPath becomes the loader instead of claude, so argv0=rg no longer re-execs claude and ripgrep disappears. Musl + PT_INTERP patching is fewer files AND keeps self-exec intact.

  • A nix dockerTools build of the images. Rejected: nixpkgs claude-code lags the compat pin, so a nix build would ship whatever version nixpkgs currently packages rather than the version the compat record was verified against. Fetching the upstream artifact directly and pinning it by sha256 in versions.env keeps the compat record the single authority over which CLI version ships, and keeps the version a plain build input a bump can move without waiting on nixpkgs.

  • One fat multi-provider image carrying every CLI. Rejected: providers version independently (a claude bump should not re-tag codex), per-provider tags give a consumer exactly the CLI it spawns, and a scratch single-CLI image is a smaller pull than a bundle of all of them. Per-provider images also make the gemini seam a pure addition (a versions.env entry + a Containerfile following the pattern) rather than a rebuild of a shared image.

Consequences

  • /opt/lab is baked into the claude binary. The PT_INTERP rewrite hard-codes /opt/lab/lib/ld-musl-x86_64.so.1; the runner MUST mount at /opt/lab. Changing the mount destination is an image-rebuild, not a runner flag.
  • The digest, not the tag, is the consumer contract. A same-tag re-push moves the tag; anything pinning the tag can silently pick up a rebuilt image. #205 pins @sha256:… from the release job's summary.
  • The compat-record → versions.env coupling has one failure mode to guard against: a CLI version bump in versions.env WITHOUT a compat re-verification. The compat record is the checklist and must be re-run against the new version before the version moves; a bump that skips it ships an unverified CLI under a pin that claims verification. The bump procedure orders re-verify-then-bump for exactly this reason.
  • gemini is a ready seam, not code. When the gemini adapter (#126) lands, the image is a versions.env entry plus a Containerfile following the claude/codex pattern — no change to the mechanism, the CI legs, or the injection contract.
  • x86_64-only today. Both upstreams publish arm64(-musl) artifacts, so an arm64 variant is a mechanical follow-up once an arm64 runner/host exists — a second set of tags off the same versions.env and Containerfiles.