# =============================================================================
# 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=
# Stage `agent-runner` is the image that ships; stage `verify` (end of file) is a TEST stage built
# FROM it that only runs assertions and is never pushed. build-image.sh and the CI pipeline
# build `--target verify` to prove the image and `--target agent-runner` for the image itself.
FROM ${AGENT_BASE_IMAGE} AS agent-runner

# 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"]

# =============================================================================
# verify — a TEST stage, never shipped. Every assertion infra/agent/build-image.sh used to make
# with `run --user 65532 --read-only --tmpfs /workspace --tmpfs /tmp --network none` now runs
# here, at build time, because CI builds on rootless BuildKit where no container runtime exists.
#
# WHAT A BUILD STEP CAN AND CANNOT REPRODUCE, stated so nobody over-reads a green verify:
#   * uid 65532 ............ exact: USER is inherited from the agent-runner stage.
#   * --network none ....... exact: RUN --network=none, and the first check proves it.
#   * --read-only rootfs ... NOT reproducible: a RUN step always has a writable layer. It is
#                            APPROXIMATED by permission: as uid 65532, every path the process can
#                            write is inventoried and must lie under /workspace, /tmp or /var/tmp
#                            (the pod mounts emptyDirs on the first two; /var/tmp is read-only
#                            there, and TMPDIR points into /workspace so nothing should use it).
#                            The pod's readOnlyRootFilesystem is enforced by admission
#                            (platform/kyverno/admission/pm-agent-job-shape.yaml), not by this.
#   * --tmpfs /workspace ... approximated by the image's own /workspace (owned 65532), which is
#                            what the pod's emptyDir replaces.
# The probe script is COPYed into THIS stage only, so it never ships in the image.
# =============================================================================
FROM agent-runner AS verify
COPY probe-validation.sh /opt/verify/probe-validation.sh
RUN --network=none /bin/bash -euo pipefail <<'VERIFY'
echo "--- no network (the build-time stand-in for run --network none) ---"
if getent hosts registry.npmjs.org >/dev/null 2>&1 || curl -sS --max-time 3 -o /dev/null https://1.1.1.1 2>/dev/null; then
  echo "UNEXPECTED: network reachable in the verify stage"; exit 1
fi
echo "    confirmed: no network"
echo "--- id ---";                id
echo "--- HOME ---";              echo "HOME=${HOME}  CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}"
echo "--- baked versions ---";    cat /etc/agent-runner-versions
echo "--- the gate toolchain the worker needs (CLAUDE.md §3) ---"
for t in git python3 bash yamllint check-jsonschema kyverno promtool chainsaw flux sops age kubectl jq diff; do
  if command -v "$t" >/dev/null 2>&1; then printf "    ok      %s\n" "$t";
  else printf "    MISSING %s\n" "$t"; missing=1; fi
done
[ -z "${missing:-}" ] || { echo "the gate cannot run in this image"; exit 1; }
echo "--- python can import the renderer dependency ---"
python3 -c "import yaml; print('PyYAML', yaml.__version__)"
echo "--- claude ---";             claude --version
echo "--- the two entrypoints refuse arguments ---"
# Each must REFUSE. (The old docker-run version wrote `cmd | grep -q ... && echo`, which under
# `set -e` + pipefail could never fail the script; these are real assertions.)
for e in pm-worker pm-dispatch; do
  out="$(/usr/local/bin/$e --anything 2>&1 || true)"
  grep -q "takes no arguments" <<<"$out" || { echo "UNEXPECTED: $e accepted an argument: $out"; exit 1; }
  echo "    $e: rejected"
done
echo "--- the dispatcher selftest (branch vocabulary, result envelopes, Job template, persona) ---"
PM_SELFTEST=1 /usr/local/bin/pm-dispatch
echo "--- the entrypoints are root-owned and NOT writable by the runtime uid ---"
for f in /usr/local/bin/pm-worker /usr/local/bin/pm-dispatch /etc/pm-agent/worker-job-template.yaml; do
  stat -c "    %n %U:%G %a" "$f"
  if [ -w "$f" ]; then echo "UNEXPECTED: uid $(id -u) can write $f"; exit 1; fi
done
echo "    confirmed: not writable by uid $(id -u)"
echo "--- the root filesystem is not writable by the runtime uid (expected: a failure) ---"
if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: / is writable by uid $(id -u)"; exit 1; fi
echo "    confirmed: / is not writable"
echo "--- everything uid $(id -u) CAN write lies under /workspace, /tmp or /var/tmp ---"
stray="$(find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -writable ! -type l -print 2>/dev/null \
         | grep -vE '^/(workspace|tmp|var/tmp)(/|$)' || true)"
if [ -n "${stray}" ]; then echo "UNEXPECTED: writable outside the allowed paths:"; printf '      %s\n' ${stray} | head -20; exit 1; fi
echo "    confirmed: no writable path outside /workspace, /tmp, /var/tmp"
echo "--- the two writable paths really are writable ---"
touch "${HOME}/.probe" /tmp/.probe || { echo "UNEXPECTED: ${HOME} or /tmp not writable"; exit 1; }
echo "    confirmed: ${HOME} and /tmp are writable"
echo "--- uid is non-zero ---"
[ "$(id -u)" -ne 0 ] || { echo "UNEXPECTED: running as root"; exit 1; }
echo "    confirmed: uid $(id -u), not root"
echo "--- no Anthropic API key is baked into the image (it would outrank the subscription token) ---"
[ -z "${ANTHROPIC_API_KEY:-}${ANTHROPIC_AUTH_TOKEN:-}" ] || { echo "UNEXPECTED: an Anthropic key is baked in"; exit 1; }
echo "    confirmed: neither is set"
echo "--- pm-worker's input validation, with the interpreter and script that run in the cluster ---"
PM_WORKER_PATH=/usr/local/bin/pm-worker /bin/bash /opt/verify/probe-validation.sh
echo "AGENT-RUNNER VERIFIED"
VERIFY
