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. Taggit.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'slinux-x64-muslnative 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/labis a HARD requirement. The runner mounts the digest-pinned image into the operator's chosen dev container viapodman run --mount type=image,src=…@sha256:…,dst=/opt/laband prepends/opt/lab/binto PATH, so the binaries resolve at/opt/lab/bin/claudeetc. 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_INTERPrewrite — 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, pointPT_INTERPat it, and neither base's libc is ever consulted. codex's linux musl build is static-pie with noPT_INTERPand noDT_NEEDED, so it runs as-is;labctlis pure Go builtCGO_ENABLED=0, statically linked with no loader reference. -
PT_INTERPpatching, 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 thatexecs/opt/lab/lib/ld-musl… /opt/lab/libexec/claude, breaks claude: the binary re-execsprocess.execPathunderargv0=rgto serve its EMBEDDED ripgrep, and under an explicit loaderprocess.execPathwould be the loader, not claude — ripgrep gone. PatchingPT_INTERPleaves the binary self-identifying, so self-exec stays intact (verified empirically: the patched binary answers--versionand still serves the embedded ripgrep). Any future change to how claude is packaged must preserve self-exec. -
containers/agent-tools/versions.envis the single source of truth for CLI versions and artifact checksums, and it is coupled to the compat records. It pinsCLAUDE_CODE_VERSION=2.1.198+ the sha256 of thelinux-x64-muslbinary (from Anthropic's per-versionmanifest.jsonatdownloads.claude.ai/claude-code-releases/<ver>/manifest.json) andCODEX_VERSION=0.133.0+ the sha256 ofcodex-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) andinternal/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. alabctlrefresh at the same CLI version) produces a new digest under the same tag. Consumers (#205) pinagent-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 stockdebian:stable-slimandalpine, runclaude --version/codex --version/labctl --help) touching no secret and never pushing, and a release leg (push to main path-gated oncontainers/**+ the workflow, plusworkflow_dispatchfor a labctl-only refresh) that re-runs the smoke test then pushes. The publish job fails loud with provisioning instructions whenFORGE_REGISTRY_TOKENis 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 andlabctl. 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/webare 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-x64build 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.execPathbecomes the loader instead of claude, soargv0=rgno longer re-execs claude and ripgrep disappears. Musl +PT_INTERPpatching is fewer files AND keeps self-exec intact. -
A nix
dockerToolsbuild of the images. Rejected: nixpkgsclaude-codelags 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/labis baked into the claude binary. ThePT_INTERPrewrite 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.