feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped

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]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-10-05 22:28:10 -05:00
1 parent 22c94effff
commit c5b5b8672a
32 files changed
+5102

No files matched your search

+7
View File
@@ -71,4 +71,11 @@ resources:
# controller must NEVER gate the platform chain; same non-gating rationale as headlamp/oauth2-proxy.
# Carries velero-cloud.sops.yaml, so clusters/dev adds a SOPS decryption patch for it (by name).
- backup.yaml # Flux Kustomization -> ./platform/velero (dependsOn platform-config)
# PM agent — reads Gitea issues and runs one worker agent per assignment that authors a
# change, gates it and opens a pull request. POST-APP tier (dependsOn apps-wave-0,
# wait:false): what it needs is GITEA, not Keycloak or the Gateway, and it must never gate
# anything — it CrashLoops by design while its credentials are still placeholders. Carries
# pm-agent-credentials.sops.yaml, so both overlays add a SOPS decryption patch for it (by
# name). See clusters/base/pm-agent.yaml and platform/pm-agent/README.md.
- pm-agent.yaml # Flux Kustomization -> ./platform/pm-agent (dependsOn apps-wave-0)
- cluster-vars.yaml # overlay knob surface (base defaults)
+57
View File
@@ -0,0 +1,57 @@
---
# Flux Kustomization: the PM agent service — reconciles ./platform/pm-agent.
#
# POST-APP TIER (dependsOn apps-wave-0). What this service actually depends on is GITEA: the
# dispatcher authenticates to it at startup, reads issue threads from it, and every worker
# clones and pushes to it. Gitea is in apps-wave-0, so that is the dependency — not
# platform-config, which brings up the Keycloak realm, the wildcard certificate, the Gateway
# and the L2 pool. This service has no UI, serves no traffic, takes no ingress and holds no
# certificate, so none of those are prerequisites and claiming them would be a lie that
# delays its reconcile behind four things it does not use.
#
# NON-GATING (wait:false), and here that is not merely prudent — the pod is EXPECTED to be
# unhealthy for a while:
# * the committed credentials are PLACEHOLDERS, so until the operator replaces them the
# dispatcher fails its Gitea authentication at startup and CrashLoops, by design
# (platform/pm-agent/pm-agent-credentials.sops.yaml.example);
# * the image digest is likewise a placeholder until the operator pushes the image, so
# before that the pod cannot even pull.
# Either of those inside a wait:true tier would stall everything downstream of it. wait:false
# guarantees this service's health can NEVER gate anything, even if some future resource is
# mistakenly pointed dependsOn it. Same non-gating leaf rationale as
# headlamp/homepage/crafty/backup — with a stronger reason than any of them.
#
# PRUNE: true. The whole namespace is disposable — the dispatcher keeps no durable state (the
# Gitea issue thread is its state) and worker Jobs are ephemeral by construction. So removing
# this service from git really should remove it from the cluster, with nothing to orphan.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: pm-agent
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
prune: true
wait: false
dependsOn:
- name: apps-wave-0 # Gitea: the repository this service reads issues from and pushes to
sourceRef:
kind: GitRepository
name: flux-system
path: ./platform/pm-agent
# cluster-vars is substituted for parity with every other tier. NOTE for anyone editing
# platform/pm-agent/: envsubst runs over the WHOLE rendered manifest, comments included, so
# a `${...}` meant for a shell would be replaced with the empty string before the container
# starts (the hazard catalog/README.md documents at length — it silently killed gitea's and
# nextcloud's OIDC registration for days). Nothing under platform/pm-agent/ uses a `${}`
# token today, and the two scripts that DO contain shell live in the image, not in a
# manifest, which is one more reason they are baked in rather than mounted from a ConfigMap.
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
# SOPS decryption for pm-agent-credentials.sops.yaml is layered per-environment by the
# overlay (clusters/prod and clusters/dev patch THIS Kustomization by name), identical to
# crafty/headlamp/oauth2-proxy/backup.
+19
View File
@@ -122,6 +122,25 @@ patches:
provider: sops
secretRef:
name: sops-age
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
# account and the Anthropic subscription token from `claude setup-token`. Without this
# stanza kustomize hands Flux the raw SOPS document and the Secret applies full of
# ciphertext: the APPLY SUCCEEDS, and the only symptom is a dispatcher that cannot
# authenticate to Gitea — so this stanza is as load-bearing as the file it decrypts (same
# silent-failure shape as the apps-vms note below/above). The `sops-age` Secret is created
# out-of-band (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: pm-agent
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# apps-vms (the KubeVirt VM tier, clusters/base/apps-vms.yaml) gained its FIRST encrypted Secret
# on 2026-09-14: apps/vms/unifi/unifi-uos-installer.sops.yaml, the operator's UOS installer URL,
# which 30-virtualmachine.yaml mounts into the guest as a disk. Without this stanza kustomize
+19
View File
@@ -113,6 +113,25 @@ patches:
provider: sops
secretRef:
name: sops-age
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
# account and the Anthropic subscription token from `claude setup-token`. Without this
# stanza kustomize hands Flux the raw SOPS document and the Secret applies full of
# ciphertext: the APPLY SUCCEEDS, and the only symptom is a dispatcher that cannot
# authenticate to Gitea — so this stanza is as load-bearing as the file it decrypts (same
# silent-failure shape as the apps-vms note below/above). The `sops-age` Secret is created
# out-of-band (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: pm-agent
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# apps-vms (the KubeVirt VM tier, clusters/base/apps-vms.yaml) gained its FIRST encrypted Secret
# on 2026-09-14: apps/vms/unifi/unifi-uos-installer.sops.yaml, the operator's UOS installer URL,
# which 30-virtualmachine.yaml mounts into the guest as a disk. Without this stanza kustomize