# ============================================================================= # agent-runner — the image behind the PM agent service (platform/pm-agent/). # # ONE image, TWO entrypoints, because they must agree on everything that matters: # # /usr/local/bin/pm-dispatch the long-running PROGRAM MANAGER. Polls Gitea issues, # asks the model what to do about each thread, posts the # answer as an issue comment, and creates a worker Job # when the answer is "implement" or "revise". # /usr/local/bin/pm-worker one ASSIGNMENT. Clones the repo, branches, runs a full # Claude Code session against CLAUDE.md, runs ./ci/gate.sh, # pushes the branch and opens the PR. # # WHY IT IS LAYERED ON THE DEV TOOLBOX, NOT BUILT FROM SCRATCH. # A worker's last act before pushing is `./ci/gate.sh`, and CLAUDE.md §3 says in terms # that only the PINNED TOOLBOX result counts — gating on a host without promtool and the # rest produces a false rc 3. The toolbox (infra/devcontainer/) already pins that whole # toolchain in one place. Re-listing those pins here would create a second source of truth, # and the first time the two drifted a worker's "gate green" would stop meaning the gate's # green while still reading as a pass. So the base IS the toolbox, and this file adds only # what the toolbox lacks: node, the Claude Code CLI, and the two entrypoints. # # The cost is honest and worth naming: the toolbox is a full Fedora with ansible and # OpenTofu in it, so this image is large (~2 GiB) for a workload that needs neither. The # alternative — a slim image with a hand-copied subset of the toolchain — buys size and # pays with exactly the drift the paragraph above describes. Size is the cheaper problem. # # THE ADMISSION CONTRACT THIS IMAGE MUST SATISFY. # platform/kyverno/admission/pm-agent-job-shape.yaml is a fail-closed Enforce ClusterPolicy # and it is the security boundary — not the dispatcher's RBAC. Kubernetes places no `use` # check on a Pod's `serviceAccountName`, so any principal that can `create jobs` in the # pm-agent namespace can create a Job with an arbitrary command; the policy is what stops # that. Three consequences are baked in below: # # 1. `USER` is NUMERIC. With `runAsNonRoot: true` and no explicit `runAsUser`, the # kubelet REJECTS an image whose USER is a name it cannot resolve to a uid # ("container has runAsNonRoot and image has non-numeric user"). So: USER 65532:65532. # 2. The worker pod is `readOnlyRootFilesystem: true` and the policy admits only the # `workspace` and `tmp` volumes. So HOME and every cache path point INTO /workspace, # never at the read-only root — that is why HOME=/workspace below, and why # CLAUDE_CONFIG_DIR is set (Claude Code writes its config and credentials under it, # and would otherwise try $HOME/.claude on a path that may not be writable). # 3. `command` is pinned to one of the two absolute paths above, with NO args. Both # scripts therefore take NO arguments at all and read their whole input from the # environment, which they validate themselves. An argument surface on a pinned # command is caller-supplied code; there is none here. # ============================================================================= # The pinned dev toolbox. Passed in by build-image.sh from infra/agent/versions.env; the # default is deliberately EMPTY so a build that forgets it fails loudly instead of # resolving some other image named `agent-base`. ARG AGENT_BASE_IMAGE= FROM ${AGENT_BASE_IMAGE} # Re-declared AFTER the FROM: an ARG from before the first FROM is not in scope in the # build stage, so the provenance layer below would otherwise record an empty base. ARG AGENT_BASE_IMAGE= ARG NODEJS_MAJOR= ARG CLAUDE_CODE_VERSION= ARG CLAUDE_CODE_MIN_VERSION= ARG AGENT_MODEL= # Nonroot uid/gid. 65532 is the distroless `nonroot` convention; the value is arbitrary # but it MUST be numeric and non-zero — see "THE ADMISSION CONTRACT" above. ARG RUNNER_UID=65532 ARG RUNNER_GID=65532 LABEL org.opencontainers.image.title="agent-runner" \ org.opencontainers.image.description="pinned dev toolbox + Claude Code CLI: the PM dispatcher and the per-assignment worker agent" \ org.opencontainers.image.source="https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl" \ org.opencontainers.image.documentation="platform/pm-agent/README.md" \ homelab.keithjackson.dev/requirements="R-SEC-02,R-GITOPS-02" # Fail the BUILD — not a worker Job three weeks later — if a pin was not passed in. RUN set -eux; \ : "${NODEJS_MAJOR:?pass --build-arg NODEJS_MAJOR from infra/agent/versions.env}"; \ : "${CLAUDE_CODE_VERSION:?pass --build-arg CLAUDE_CODE_VERSION from infra/agent/versions.env}"; \ : "${AGENT_MODEL:?pass --build-arg AGENT_MODEL from infra/agent/versions.env}" # --- node + the Claude Code CLI ---------------------------------------------- # The CLI is a node program; the Fedora base carries a nodejs module stream, which is the # reproducible way to get node inside an already-pinned base (no second distro, no curl | # bash installer). `npm i -g` with an EXACT version — a floating tag would make the image # non-reproducible, and the in-image autoupdater is disabled at runtime anyway. # # The version is then ASSERTED, twice: that the binary runs at all, and that it is at or # above CLAUDE_CODE_MIN_VERSION. Both entrypoints pass `--permission-prompts none`, which # requires >= 2.1.259 (docs/en/cli-reference). Below that floor the flag is unknown and an # unattended run waits for a permission answer nobody can give — a hang, not an error, which # is the worst failure mode available. Catch it here. RUN set -eux; \ dnf -y module enable "nodejs:${NODEJS_MAJOR}" || true; \ dnf -y install --setopt=install_weak_deps=False nodejs npm; \ dnf clean all; \ npm install -g --no-fund --no-audit "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"; \ npm cache clean --force; \ claude --version; \ installed="$(claude --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"; \ [ -n "${installed}" ] || { echo "cannot read claude --version" >&2; exit 1; }; \ lowest="$(printf '%s\n%s\n' "${installed}" "${CLAUDE_CODE_MIN_VERSION}" | sort -V | head -1)"; \ [ "${lowest}" = "${CLAUDE_CODE_MIN_VERSION}" ] || { \ echo "claude ${installed} is below the ${CLAUDE_CODE_MIN_VERSION} floor needed for --permission-prompts none" >&2; \ exit 1; } # --- the two entrypoints ----------------------------------------------------- # Copied as files at FIXED paths with NO argument surface, because the admission policy # pins `command` and reads nothing else: a container with `command: ["/bin/sh","-c"]` and # an arbitrary script would otherwise be admitted and run with whatever the worker holds. # Pinning a path is only worth anything if the thing at that path cannot be replaced, so # ownership and mode are ASSERTED below rather than assumed, and both scripts are # syntax-checked at build time. COPY pm-dispatch.py /usr/local/bin/pm-dispatch COPY pm-worker /usr/local/bin/pm-worker COPY worker-job-template.yaml /etc/pm-agent/worker-job-template.yaml COPY pm-persona.md /etc/pm-agent/pm-persona.md COPY worker-rules.md /etc/pm-agent/worker-rules.md RUN set -eux; \ chown root:root /usr/local/bin/pm-dispatch /usr/local/bin/pm-worker; \ chmod 0755 /usr/local/bin/pm-dispatch /usr/local/bin/pm-worker; \ chown -R root:root /etc/pm-agent; \ chmod 0644 /etc/pm-agent/*; \ python3 -c 'import ast,sys; ast.parse(open("/usr/local/bin/pm-dispatch").read())'; \ bash -n /usr/local/bin/pm-worker; \ test "$(stat -c '%U:%G %a' /usr/local/bin/pm-worker)" = 'root:root 755'; \ test "$(stat -c '%U:%G %a' /usr/local/bin/pm-dispatch)" = 'root:root 755' # Both entrypoints refuse arguments. Prove it here, in the layer, so a rule that pins # `command` with no args is backed by a program that would reject them anyway. RUN set -eux; \ /usr/local/bin/pm-worker --anything 2>&1 | grep -q 'takes no arguments'; \ /usr/local/bin/pm-dispatch --anything 2>&1 | grep -q 'takes no arguments' # --- the nonroot account ----------------------------------------------------- # Written directly rather than pulling in shadow-utils to run useradd once. HOME is # /workspace because at runtime that emptyDir is the only writable path. RUN set -eux; \ echo "agent:x:${RUNNER_UID}:${RUNNER_GID}:pm agent:/workspace:/bin/bash" >> /etc/passwd; \ echo "agent:x:${RUNNER_GID}:" >> /etc/group; \ install -d -m 0775 -o "${RUNNER_UID}" -g "${RUNNER_GID}" /workspace # SYSTEM-level git config, because HOME is an emptyDir that starts empty on every run and # the root filesystem is read-only — a runtime `git config --global` write would fail. # # safe.directory=*: the clone and the checks that walk it can run under different uids in # the same volume, and git aborts with "detected dubious ownership" on a mismatch. The repo # is cloned fresh per run into an emptyDir, so there is no pre-existing hostile checkout # this relaxes a check against. # # The identity is NOT set here. A commit's author is per-assignment (it names the issue the # work came from), so pm-worker passes it in the environment; see the script. RUN set -eux; \ printf '%s\n' \ '[safe]' \ ' directory = *' \ '[advice]' \ ' detachedHead = false' \ '[init]' \ ' defaultBranch = main' \ > /etc/gitconfig; \ chmod 0644 /etc/gitconfig # Provenance: what is actually in this layer, readable from a running pod. RUN set -eux; \ { echo "base=${AGENT_BASE_IMAGE}"; \ echo "nodejs=$(node --version)"; \ echo "npm=$(npm --version)"; \ echo "claude-code=$(claude --version)"; \ echo "claude-code-pin=${CLAUDE_CODE_VERSION}"; \ echo "model-default=${AGENT_MODEL}"; \ echo "python3=$(python3 --version)"; \ echo "git=$(git --version)"; \ echo "pm-dispatch=$(sha256sum /usr/local/bin/pm-dispatch | cut -d' ' -f1)"; \ echo "pm-worker=$(sha256sum /usr/local/bin/pm-worker | cut -d' ' -f1)"; \ } > /etc/agent-runner-versions; \ chmod 0444 /etc/agent-runner-versions # Every writable path points into the `workspace` emptyDir — the root filesystem is # read-only in the worker pod and the admission policy admits no third volume for scratch. # # CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC turns off auto-updates, telemetry, error # reporting AND feature-flag fetching in one variable (docs/en/env-vars). That is not a # privacy gesture: this namespace's egress policy allows tcp/443 to the world and # gitea only, so a background call to any other host is a connection that hangs until it # times out, inside a Job with a deadline. Turning the traffic off is how the deadline # stays about the work. # # ANTHROPIC_API_KEY is deliberately NOT set and MUST NOT be: Claude Code's credential # precedence puts an API key ABOVE CLAUDE_CODE_OAUTH_TOKEN, so an empty or stale key in # the environment would silently take over from the subscription token and bill (or fail) # somewhere else entirely. See platform/pm-agent/README.md. ENV HOME=/workspace \ XDG_CACHE_HOME=/workspace/.cache \ XDG_CONFIG_HOME=/workspace/.config \ TMPDIR=/workspace/.tmp \ CLAUDE_CONFIG_DIR=/workspace/.claude \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ NPM_CONFIG_CACHE=/workspace/.cache/npm \ PYTHONDONTWRITEBYTECODE=1 \ PM_AGENT_MODEL=${AGENT_MODEL} \ PM_WORKER_JOB_TEMPLATE=/etc/pm-agent/worker-job-template.yaml \ PM_PERSONA_FILE=/etc/pm-agent/pm-persona.md \ PM_WORKER_RULES_FILE=/etc/pm-agent/worker-rules.md USER ${RUNNER_UID}:${RUNNER_GID} WORKDIR /workspace # No ENTRYPOINT on purpose. The admission policy pins `command` to one of the two absolute # paths above; an ENTRYPOINT here would either be ignored or prepend something the policy # cannot see. `bash` is the interactive fallback for a human debugging the image locally. CMD ["/bin/bash"]