# ============================================================================= # 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 " \ # 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