The homelab agent platform's harness image family. One small repo, one responsibility: build and
publish the container image that agent sessions run in. Design + operation live in the
homelab repo (docs/agents/, ADR-077/078/081); this
repo only produces the artifact.
The image needs a build-and-publish pipeline (docker → ghcr, versioned, Renovate). Bolting that onto
the homelab IaC monorepo would mean path filters and "what does this push build/deploy?" rules on a
repo that otherwise just applies Tofu/Ansible. Here the rule is trivial: every push to master
builds. Same Tier-A pattern as the app repos (sleep-tracking, snore-recorder) — homelab is the
consumer (it references the image by tag), not the builder.
FROM jetpackio/devbox + the harnesses pinned via devbox/nix: goose-cli + opencode +
claude-code + codex (plus the basic shell tools agents reach for so they don't fall back to
writing Python: gh, git, ripgrep, curl, wget, jq). It deliberately bakes no project toolchain — the per-project
python/uv/pytest/… is materialized at runtime from the cloned repo's own devbox.json
(boot-from-git), so the image stays lean and project-agnostic. One image, two launch modes (the
launcher lives in homelab as agents/agent-session.sh):
- non-interactive — a recipe runs headless → branch + PR
- interactive — you
execa shell, drive goose/opencode with model overrides → branch + PR
The only seam in/out is git (clone at base ref → branch → push) — the contract borrowed from Daytona's opencode-plugin, on a self-hosted, ephemeral k8s pod instead of hosted persistent sandboxes.
codex is the fourth harness (agent-runtime#146). The launcher owns the command; the image owns the
contract, via agent-codex:
agent-codex "<prompt>" # → codex exec --json "<prompt>"- runs in the prepared
$WORKDIR(never the caller's cwd); - invokes
codex exec --json, so the transcript is JSONL, one event per line; - tees stdout+stderr into
$RUN_LOG(default/tmp/run.log) — the fileagent-finalizereads; - exits with Codex's own status, unchanged.
agent-finalize decodes that JSONL: parse_outcome() reads the last item.completed event
whose item.type is agent_message and extracts the recipe's contract from its text (on disk it
is JSON-string escaped, so the raw-log regex cannot see it), and decodes turn.failed / top-level
error events as failure evidence. The bare-object path the other harnesses use is unchanged.
Config is user-level and runtime-supplied: Codex reads $CODEX_HOME/config.toml (default
~/.codex). The image bakes no auth.json — a custom model_providers.<id> with
command-backed bearer auth (auth.command/args/cwd/timeout_ms/refresh_interval_ms) is the
launcher's config, not the image's. The entrypoint creates $CODEX_HOME and never writes a
credential there.
The image tag is content-addressed by agent-base/devbox.lock: YYYY-MM-DD-<lockhash8>. The lock
pins the exact nixpkgs closure, so the same lock rebuilds bit-for-bit, and a Renovate lock bump is
what cuts a new image. :latest tracks master.
bash scripts/build-image.sh # build only (needs docker)
PUSH=true bash scripts/build-image.sh # build + push (after `docker login ghcr.io`)CI does the same on every master push (.github/workflows/build-image.yaml, runner
homelab-ephemeral, ghcr via the job GITHUB_TOKEN).
agent-base/
Dockerfile FROM jetpackio/devbox; devbox install the harnesses
devbox.json/.lock the harness pins (goose-cli, opencode, gh, git, rg, jq)
entrypoint.sh git-only seam: clone → branch → project devbox install → exec harness/shell
agent-codex headless Codex seam: codex exec --json in $WORKDIR → $RUN_LOG, exit preserved
scripts/build-image.sh reproducible build, tag-by-lockhash, cosign/SBOM stubs (SLSA-L2 TODO)
.github/workflows/build-image.yaml
- Create the GitHub repo
teststuffstash/agent-runtime, pushmaster. - First image push: make the ghcr package public (or wire a pull secret) — the "required click".
- Enable Renovate.
- Profiles — a lean
fiximage (current) and a heavygateimage (k3d + Playwright) for the full-stack confidence test; both built here with the same one-line workflow. - SLSA-L2 — flip on the cosign-sign + SBOM-attest stubs (homelab
docs/slsa.md). - Digest-pin the base — Renovate the
jetpackio/devboxFROMto a@sha256:digest.