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]>
148 lines
9.2 KiB
Plaintext
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
|