Files
home-network-k3s-impl/platform/pm-agent/pm-agent-credentials.sops.yaml.example
jakman85andClaude Opus 5.5 7f0cbc7ba2
gate / gate (pull_request) Failing after 2m56s
images / images (pull_request) Successful in 32s
gate / conformance (pull_request) Has been skipped
feat(pm-agent): pin the first published agent-runner digest, start dry, and fix the token scopes
Rebased #61 onto the CI image pipeline (#81). This commit makes the remaining in-git
blockers to going live go away:

- deployment.yaml and PM_WORKER_IMAGE pin images run 340's digest (sha256:c4eba5b3...,
  tag sha-523cd9118f95 = main), read from the registry. Renovate markers unchanged.
- PM_DRY_RUN=true and PM_REQUIRE_LABEL=agent for the first run. Dry run is required, not
  just cautious: the run-340 image's baked worker template predates imagePullSecrets, so
  admission would deny its Jobs until CI re-publishes after this merge and Renovate bumps.
- Token scopes: Gitea 1.24.3 scopes GET /api/v1/user (the dispatcher's first call) under
  read:user. The documented list was missing it. Now read:user, write:repository,
  write:issue, read:package everywhere.
- Org-package read documented as a team (Packages=Read, no repos), not just a scope.
- sops is toolbox-only; the .examples say so and give the in-place edit.
- runbook: key-name check no longer emits Secret values before the pipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:16:21 -05:00

148 lines
9.2 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, exactly these four (one token serves both Secrets — see
# pm-agent-registry.sops.yaml.example):
# read:user GET /user, the dispatcher's startup identity check. Gitea 1.24.3
# puts the whole /api/v1/user group behind the `user` scope
# category (routers/api/v1/api.go), so without it the dispatcher
# CrashLoops on its first call even with an otherwise-valid token.
# write:repository the clone, the branch push, the pull-request create, and the
# dispatcher's reads of pulls/branches/commits.
# write:issue reading open issues and posting issue comments.
# read:package the kubelet's image pull (pm-agent-registry).
# Nothing else — in particular NOT `write:admin`, NOT `write:organization`, NOT
# `write:user`. (Until 2026-10-30 this list said two scopes; `read:user` was missing
# and `read:package` lived only in the registry template.)
#
# (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:
#
# `sops` IS IN THE PINNED TOOLBOX, NOT ON THE HOST (measured 2026-10-30: absent from the
# workstation's PATH; sops 3.9.4 and vi in the toolbox). The simplest path needs no
# plaintext file at all — edit the committed, already-encrypted file in place, from a
# worktree, the way runbooks/vpn-remote-access.md does for its key:
#
# ./infra/devcontainer/dev.sh run sops platform/pm-agent/pm-agent-credentials.sops.yaml
#
# It opens vi on the decrypted document; replace the REPLACE-ME value(s), save, quit, and
# sops re-encrypts on write. If you take the cp route below instead, run the `sops -e -i`
# line through `./infra/devcontainer/dev.sh run` as well, and delete any plaintext temp file.
#
# 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: read:user, write:repository, write:issue, read:package.
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