The pm-agent image pull failed TLS verification, and the previous entry on this branch
recorded it honestly as not diagnosed. Diagnosed now, and it is not about the image.
containerd pulls as a NODE process, so it resolves through node_baseline_dns_servers (the
LAN gateway, deliberately not PiHole) and gets the public answer: a CNAME to
wan.home.keithjackson.dev -> 136.51.115.252, measured via both 1.1.1.1 and 8.8.8.8. This
router does not hairpin, so WAN:443 answers with its own placeholder certificate —
measured with the right SNI from the workstation:
subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
No extensions in certificate
No extensions means no subjectAltName, which is exactly 'certificate is not valid for any
names'. Meanwhile 192.168.2.20 IS the shared Gateway's VIP and its https listener serves
the real Let's Encrypt *.home.keithjackson.dev. The Gateway, the wildcard cert and the
digest were never implicated — only the node's answer.
This file already documents the identical failure for the Keycloak issuer, measured from
k3s-prod-2/-3 on 2026-10-03 (curl exit 60, ssl_verify_result=18), and already carries the
mechanism that fixes it. gitea is simply the second node process to need it; containerd is
a node process and nobody had added it. An operator must run the node_baseline play.
Also records, where an operator reads them, that the agent authenticates to Gitea with a
local account and a scoped PAT by decision — and why the federated alternative is not
merely unchosen but unavailable: Gitea implements only the Authorization Code Grant and
validates no third-party OIDC token, so a Keycloak service-account token cannot
authenticate to its API or to git-over-HTTP.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
127 lines
7.7 KiB
Plaintext
127 lines
7.7 KiB
Plaintext
# =============================================================================
|
|
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (`.yaml.example` is ignored by kustomize and is
|
|
# never auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
|
|
#
|
|
# WHAT THIS IS: the two credentials the PM agent service holds, and the only two. The
|
|
# dispatcher and every worker read both by name (R-IDP-04); nothing else in this repository
|
|
# references them.
|
|
#
|
|
# THE COMMITTED `pm-agent-credentials.sops.yaml` HOLDS PLACEHOLDERS. It is encrypted to the
|
|
# repository's age recipient so the kustomization builds and Flux reconciles, but its values
|
|
# are the obviously-impossible strings below. Until the operator replaces them the service
|
|
# is INERT and says so: the dispatcher authenticates to Gitea at startup, fails, and
|
|
# CrashLoops with `GET /user returned no login — is GITEA_TOKEN valid?` in its log. That is
|
|
# deliberate — a service that quietly did nothing would be worse than one that visibly
|
|
# cannot start.
|
|
#
|
|
# =============================================================================
|
|
# HAND-OFF STEPS (operator)
|
|
# =============================================================================
|
|
#
|
|
# (1) CREATE A DEDICATED GITEA ACCOUNT FOR THE AGENT. Do NOT reuse `jakman85`, and do NOT
|
|
# reuse the existing `gitea-admin` agent account. Both hold `owner` permission on this
|
|
# repository, and `infra/gitea/approval-policy.json` authorises `/approve-override` by
|
|
# LIVE PERMISSION ALONE — so a token belonging to either would let this service clear
|
|
# the sensitive-path human review on its own pull requests. CLAUDE.md §1 forbids an agent
|
|
# doing that in the strongest terms it has, and the cheapest way to make it impossible
|
|
# rather than forbidden is for the token's account not to have the permission.
|
|
#
|
|
# Gitea -> Site Administration -> Identity & Access -> User Accounts -> Create
|
|
# username: pm-agent (any name; it appears as the author of every comment)
|
|
# NOT an administrator
|
|
# Then add it to THIS REPOSITORY as a collaborator with permission `Write`:
|
|
# Repository -> Settings -> Collaborators -> add `pm-agent`, access `Write`
|
|
#
|
|
# Verify, do not assume (CLAUDE.md §2b) — this must print `write`, never `admin` or
|
|
# `owner`:
|
|
#
|
|
# curl -s -H "Authorization: token <the token from step 2>" \
|
|
# https://gitea.home.keithjackson.dev/api/v1/repos/Home-Network/home-network-k3s-impl/collaborators/pm-agent/permission \
|
|
# | python3 -c 'import json,sys; print(json.load(sys.stdin).get("permission"))'
|
|
#
|
|
# HONEST LIMIT, read it before deciding this is enough: `Write` in Gitea includes the
|
|
# ability to MERGE a pull request, and no token scope separates "push a branch and open a
|
|
# pull request" from "merge it". The service never issues a merge call — pm-worker's code
|
|
# is the whole set of API calls it makes, and there is no merge among them — but that is
|
|
# a property of the code, not of the credential. What the permission level DOES buy
|
|
# structurally is the override: a non-admin account cannot clear the sensitive-path
|
|
# review, so a sensitive-path pull request from this agent correctly sits unmergeable
|
|
# until a human reviews it.
|
|
#
|
|
# (2) MINT THE GITEA TOKEN as that user (log in as `pm-agent`, or use the admin API to
|
|
# create a token for it): Settings -> Applications -> Generate New Token.
|
|
#
|
|
# A LOCAL GITEA ACCOUNT WITH A PAT IS THE DECIDED MODEL (operator, 2026-10-06), not
|
|
# Keycloak. The agents only ever talk to Gitea, so this is the whole requirement and it
|
|
# is far simpler than federating a machine identity. It is also the only thing that
|
|
# works: Gitea's API accepts basic auth, PATs, SSH signatures and its OWN OAuth2 bearer
|
|
# tokens, and as a provider Gitea implements only the Authorization Code Grant — no
|
|
# client-credentials grant, and nothing that validates a third-party OIDC token. A
|
|
# Keycloak service-account token therefore cannot authenticate to Gitea's API or to
|
|
# git-over-HTTP. Do not "upgrade" this to Keycloak without re-reading that.
|
|
#
|
|
# Scopes: `write:repository` and `write:issue`. Nothing else — in particular NOT
|
|
# `write:admin`, NOT `write:organization`, NOT `write:user`.
|
|
#
|
|
# `write:repository` covers the clone, the branch push and the pull-request create;
|
|
# `write:issue` covers reading and posting issue comments. (The repository's OTHER CI
|
|
# token, `SETTINGS_API_TOKEN`, is a separate credential with `read:admin` — do not
|
|
# conflate them, and do not give this service that one. The 2026-10-02 measurement on
|
|
# PR #38 recorded in infra/gitea/approval-policy.json is about that token, not this one.)
|
|
#
|
|
# (3) MINT THE ANTHROPIC SUBSCRIPTION TOKEN. This service authenticates with a Claude
|
|
# SUBSCRIPTION (Pro, Max, Team or Enterprise), not with a Console API key. On a machine
|
|
# where you can complete a browser login:
|
|
#
|
|
# claude setup-token
|
|
#
|
|
# It opens the normal authorisation flow, prints a ONE-YEAR OAuth token, and saves it
|
|
# nowhere. Copy it into `claude-code-oauth-token` below.
|
|
#
|
|
# THREE THINGS ABOUT THIS TOKEN:
|
|
# * It EXPIRES after a year, with no in-cluster warning: Claude Code's expiry notice
|
|
# only appears for an interactive `/login` credential, never for this variable. Put
|
|
# the date in the handoff entry and in runbooks/pm-agent.md; when it lapses, every
|
|
# session fails at once and the only symptom is worker Jobs failing to authenticate.
|
|
# * It must NOT be accompanied by an `ANTHROPIC_API_KEY`. Claude Code's credential
|
|
# precedence puts an API key ABOVE this variable, so a stray key would silently
|
|
# take over and bill elsewhere (or fail). pm-worker unsets both API-key variables
|
|
# before it starts a session, and the image sets neither.
|
|
# * Usage counts against the subscription seat's own limits, shared with every other
|
|
# place that seat is used. That is the real reason PM_MAX_ACTIVE_WORKERS defaults to
|
|
# 2 — several concurrent Claude Code sessions on one seat contend for the same
|
|
# account limits, and a rate-limited worker fails mid-assignment.
|
|
#
|
|
# (4) COPY, FILL, ENCRYPT, COMMIT:
|
|
#
|
|
# cp platform/pm-agent/pm-agent-credentials.sops.yaml.example \
|
|
# platform/pm-agent/pm-agent-credentials.sops.yaml
|
|
# $EDITOR platform/pm-agent/pm-agent-credentials.sops.yaml # the two real values
|
|
# sops -e -i platform/pm-agent/pm-agent-credentials.sops.yaml
|
|
#
|
|
# The root `.sops.yaml` creation rule matches this path and encrypts only `data`/
|
|
# `stringData`, so the diff stays reviewable. NEVER `sops -d` this file into a terminal,
|
|
# a log or a transcript (CLAUDE.md §6), and never print the Secret's `.data` when
|
|
# checking that it exists — list key names only.
|
|
#
|
|
# It is already listed under `resources:` in platform/pm-agent/kustomization.yaml, so
|
|
# replacing the placeholder file is the whole change.
|
|
#
|
|
# (5) ROTATION. Both values are independent. To rotate either, repeat its step, re-encrypt,
|
|
# and merge — Flux updates the Secret and the dispatcher's pod must then be restarted to
|
|
# re-read it (a Secret change does not roll a Deployment by itself). The worker Jobs pick
|
|
# the new value up on their next run with no action at all.
|
|
# =============================================================================
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: pm-agent-credentials
|
|
namespace: pm-agent
|
|
type: Opaque
|
|
stringData:
|
|
# Gitea token for the dedicated, NON-ADMIN `pm-agent` account.
|
|
# Scopes: write:repository, write:issue.
|
|
gitea-token: REPLACE-ME-gitea-token
|
|
# Output of `claude setup-token` on a subscription account. One year's validity.
|
|
claude-code-oauth-token: REPLACE-ME-claude-code-oauth-token
|