A service that reads this repository's Gitea issues, decides what to do about each thread as the Program Manager persona, and runs one worker agent per assignment that authors a change, runs the merge gate and opens a pull request. It reacts to issue creation and to comments; the assignment and all correspondence travel through the issue thread and the PR. It authenticates with an Anthropic subscription token. It never pushes to main and never merges. Three choices carry the design, with alternatives recorded in docs/plans/: - it POLLS rather than receiving webhooks (Gitea's webhook SSRF guard, lost deliveries, and no inbound reach needed at all); - it keeps NO state: the trigger is "the newest thing in this thread was not written by me", so the issue thread is the state — no PVC, no backup obligation; - the model holds NO tools: its reply is one JSON object this code validates, so nothing in the path executes text. The boundary is platform/kyverno/admission/pm-agent-job-shape.yaml (Enforce, failurePolicy: Fail), not the RBAC: Kubernetes places no `use` check on serviceAccountName, so "may create Jobs" is "may run arbitrary code as the namespace's most privileged identity". Six rules admit only the committed shape, and the worker identity is bound to nothing with no token mounted, so there is nothing to escalate to. 28/28 offline Kyverno assertions. Nothing has run: the image has never been built or pushed, and both the image digest and the two credentials are deliberate placeholders. See the handoff entry's "What I did NOT verify". Refs CLAUDE.md §1/§3/§6, R-SEC-02, R-GITOPS-02. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
177 lines
8.4 KiB
YAML
177 lines
8.4 KiB
YAML
---
|
|
# =============================================================================
|
|
# worker-job-template.yaml — the ONE Job shape the PM dispatcher may create.
|
|
#
|
|
# WHERE THIS LIVES AND WHY. It is baked into the agent-runner image at
|
|
# /etc/pm-agent/worker-job-template.yaml (see the Containerfile), NOT mounted from a
|
|
# ConfigMap. A ConfigMap would be editable in-cluster by anyone who can patch it, and this
|
|
# file is the thing the dispatcher's RBAC + the admission policy are jointly protecting:
|
|
# the template IS the privilege boundary's shape. Baking it means changing it requires
|
|
# rebuilding and re-pushing an image, whose digest the policy then pins — a reviewed,
|
|
# attributable act rather than a `kubectl edit`.
|
|
#
|
|
# It is NOT reconciled by Flux and carries no `${VAR}` tokens, so none of the
|
|
# postBuild/envsubst escaping rules in catalog/README.md apply here.
|
|
#
|
|
# WHAT THE DISPATCHER FILLS IN, and nothing else:
|
|
# metadata.name / labels / annotations (name, issue number, branch, mode)
|
|
# spec.template.spec.containers[0].image (the digest-pinned agent-runner)
|
|
# the `value` of each env entry that HAS a literal value
|
|
#
|
|
# Every env entry with `valueFrom` is left exactly as committed here, and
|
|
# pm-dispatch.build_job REFUSES to overwrite one — so no code path can re-point a
|
|
# credential at a different Secret or key.
|
|
#
|
|
# THREE THINGS RE-CHECK THIS SHAPE, independently:
|
|
# 1. `PM_SELFTEST=1 pm-dispatch` (run by infra/agent/build-image.sh) asserts the pinned
|
|
# command, the no-args rule, the SA, the token mount, the volume set, the security
|
|
# context, and that no env name here is outside the dispatcher's allowlist;
|
|
# 2. platform/kyverno/admission/pm-agent-job-shape.yaml re-asserts the same things at the
|
|
# API server, fail-closed, for Jobs created by the pm-agent ServiceAccount — because
|
|
# Kubernetes places no `use` check on serviceAccountName, so "can create jobs" would
|
|
# otherwise be "can run anything";
|
|
# 3. platform/kyverno/tests/pm-agent/ exercises (2) with this exact shape as a fixture, so
|
|
# a template that drifts OUT of admission fails `make -C ci l0` instead of failing
|
|
# against the live API server at 3am.
|
|
# =============================================================================
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
# Replaced per assignment by the dispatcher: pm-agent-task-<issue>-<unixtime>.
|
|
name: pm-agent-task-template
|
|
namespace: pm-agent
|
|
labels:
|
|
app.kubernetes.io/name: pm-agent-worker
|
|
app.kubernetes.io/part-of: pm-agent
|
|
spec:
|
|
# NO RETRIES. A worker's side effects are a pushed branch, a pull request and an issue
|
|
# comment; a second attempt would duplicate all three, and the second run would start from
|
|
# a tree the first one already changed. A failed assignment is for a human (or the PM's
|
|
# next `revise`) to look at, not for the Job controller to repeat.
|
|
backoffLimit: 0
|
|
# 90 minutes. Long enough for a real change plus a full `./ci/gate.sh`, short enough that
|
|
# a wedged session (a hung network call, a model loop) releases its slot the same morning.
|
|
activeDeadlineSeconds: 5400
|
|
# Keep finished Jobs for 48h so `kubectl logs` still answers "what did it actually do"
|
|
# after a night; the Job controller then reaps them, so the namespace does not accumulate.
|
|
ttlSecondsAfterFinished: 172800
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: pm-agent-worker
|
|
app.kubernetes.io/part-of: pm-agent
|
|
spec:
|
|
restartPolicy: Never
|
|
# The worker's identity has NO Kubernetes API rights at all (platform/pm-agent/rbac.yaml)
|
|
# and its token is not even mounted. A worker runs model-authored code against a git
|
|
# repository; it has no business holding a cluster credential, and the cheapest way to
|
|
# guarantee it cannot use one is for it not to have one.
|
|
automountServiceAccountToken: false
|
|
serviceAccountName: pm-agent-worker
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
runAsUser: 65532
|
|
runAsGroup: 65532
|
|
fsGroup: 65532
|
|
seccompProfile:
|
|
type: RuntimeDefault
|
|
containers:
|
|
- name: worker
|
|
# Replaced by the dispatcher with the digest-pinned image. A tag here would be
|
|
# DENIED by admission; the dispatcher refuses a non-digest PM_WORKER_IMAGE at
|
|
# startup so the error names the real cause.
|
|
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
|
|
imagePullPolicy: IfNotPresent
|
|
# The pinned entrypoint, with NO args. Args on a pinned command are
|
|
# caller-supplied code; pm-worker takes none and reads its whole input from the
|
|
# environment below, which it validates itself.
|
|
command:
|
|
- /usr/local/bin/pm-worker
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
readOnlyRootFilesystem: true
|
|
privileged: false
|
|
capabilities:
|
|
drop:
|
|
- ALL
|
|
env:
|
|
# --- the assignment (literals, filled per Job by the dispatcher) -----------
|
|
- name: PM_TASK_MODE
|
|
value: implement
|
|
- name: PM_ISSUE_NUMBER
|
|
value: "0"
|
|
- name: PM_ISSUE_TITLE
|
|
value: ""
|
|
- name: PM_BRANCH
|
|
value: feat/placeholder
|
|
- name: PM_BASE_BRANCH
|
|
value: main
|
|
- name: PM_BRIEF
|
|
value: ""
|
|
- name: PM_GITEA_URL
|
|
value: http://gitea.gitea.svc.cluster.local:3000
|
|
- name: PM_GITEA_OWNER
|
|
value: Home-Network
|
|
- name: PM_GITEA_REPO
|
|
value: home-network-k3s-impl
|
|
- name: PM_GITEA_USER
|
|
value: ""
|
|
- name: PM_MODEL
|
|
value: claude-opus-5
|
|
- name: PM_MAX_TURNS
|
|
value: "80"
|
|
- name: PM_MAX_BUDGET_USD
|
|
value: "10"
|
|
# Whether a change the gate REFUSED is still pushed as a red pull request.
|
|
# Default false: the issue gets the gate log instead and nothing reaches the
|
|
# branch list. Forwarded by the dispatcher from pm-agent-config so the operator
|
|
# can flip it without rebuilding this image.
|
|
- name: PM_PUSH_ON_RED_GATE
|
|
value: "false"
|
|
# --- the two credentials (by NAME only, never a value; R-IDP-04) ----------
|
|
# Referenced from the same Secret the dispatcher reads. `optional` is absent on
|
|
# purpose: a worker that started without a credential would run, fail at the
|
|
# first push, and comment something confusing on the issue. Refusing to start is
|
|
# the clearer failure.
|
|
- name: GITEA_TOKEN
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: pm-agent-credentials
|
|
key: gitea-token
|
|
- name: CLAUDE_CODE_OAUTH_TOKEN
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: pm-agent-credentials
|
|
key: claude-code-oauth-token
|
|
resources:
|
|
# A node session plus the pinned gate toolchain. The limit is what bounds the
|
|
# blast radius of a worker on a 3-node home cluster where RAM is the binding
|
|
# constraint: at PM_MAX_ACTIVE_WORKERS=2 the worst case is 2 x 2Gi.
|
|
requests:
|
|
cpu: 250m
|
|
memory: 512Mi
|
|
limits:
|
|
cpu: "2"
|
|
memory: 2Gi
|
|
volumeMounts:
|
|
# The ONLY two writable paths. The root filesystem is read-only, and the
|
|
# admission policy admits no third volume — an extra volume is how a constrained
|
|
# pod reaches a host path, a Secret, or another SA's token.
|
|
- name: workspace
|
|
mountPath: /workspace
|
|
- name: tmp
|
|
mountPath: /tmp
|
|
volumes:
|
|
# HOME, the clone, the Claude Code config dir and every cache live here. emptyDir,
|
|
# so an assignment starts from a clean tree every time and nothing survives it: the
|
|
# branch and the pull request are the output, and they are in Gitea.
|
|
- name: workspace
|
|
emptyDir:
|
|
sizeLimit: 4Gi
|
|
# /tmp separately, because not every tool in the pinned toolchain honours $TMPDIR,
|
|
# and a read-only root filesystem turns that into a confusing mid-run failure.
|
|
- name: tmp
|
|
emptyDir:
|
|
sizeLimit: 1Gi
|