Compare commits

...
Author SHA1 Message Date
Felix FaerberandClaude Opus 4.8 79ea9f68c9 feat(ops): @ops uses its dedicated TOKEN_OPS, not AGENT_TOKEN
The gitea-admin skill now authenticates with TOKEN_OPS (the ops user's admin token), and TOKEN_OPS
is injected into the agent process ONLY when name==ops. No other agent holds an admin credential in
its env. This unhooks @ops from AGENT_TOKEN, so AGENT_TOKEN can be narrowed to a non-admin token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:52:07 +03:00
Felix FaerberandClaude Opus 4.8 6c753dc0a0 chore: repo moved to gitea/agents — update self-references
Repo transferred ffaerber/agents -> gitea/agents. Point the scripts checkout (agent.yml
repository:), the standard caller (uses:), and the README at gitea/agents. The trigger gate still
keys on the maintainer 'ffaerber' (unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:43:34 +03:00
ffaerber 23c48e66c4 Merge pull request 'ops: .env is the token source of truth (not tokens.md)' (#57) from fix/ops-env-source-of-truth into main 2026-07-05 14:01:13 +02:00
Felix Faerber 6618de9c9f ops: .env is the token source of truth (not tokens.md)
Every token value must be written to gitea/secrets/.env (the master, mirrored to org Actions secrets)
AND the Actions secret, kept in sync, removed from both on delete. Drop the tokens.md instruction —
the @ops test dutifully re-created tokens.md because the skill still referenced it.
2026-07-05 15:01:10 +03:00
ffaerber 82c4b07fea Merge pull request 'feat(ops): make @ops routable + TOKEN_OPS identity' (#56) from feat/ops-routing into main 2026-07-05 13:53:11 +02:00
4 changed files with 42 additions and 34 deletions
+9 -5
View File
@@ -1,6 +1,6 @@
name: agent name: agent
# Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues # Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues
# and invokes this via: uses: ffaerber/agents/.gitea/workflows/agent.yml@main (secrets: inherit). # and invokes this via: uses: gitea/agents/.gitea/workflows/agent.yml@main (secrets: inherit).
# The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's). # The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's).
on: on:
workflow_call: workflow_call:
@@ -54,7 +54,7 @@ jobs:
- name: Fetch shared agent scripts (this repo) - name: Fetch shared agent scripts (this repo)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
repository: ffaerber/agents repository: gitea/agents
ref: main ref: main
path: .agents-workflow path: .agents-workflow
token: ${{ secrets.GITEA_TOKEN }} token: ${{ secrets.GITEA_TOKEN }}
@@ -135,12 +135,13 @@ jobs:
- name: Set up `gitea-admin` skill (@ops only — administer the Gitea instance) - name: Set up `gitea-admin` skill (@ops only — administer the Gitea instance)
# Instance administration (orgs/users/repos/labels/secrets/scoped tokens). The SKILL.md is # Instance administration (orgs/users/repos/labels/secrets/scoped tokens). The SKILL.md is
# written ONLY for @ops (skill-gitea-admin.sh gates on NAME), so the admin how-to never # written ONLY for @ops (skill-gitea-admin.sh gates on NAME), so the admin how-to never
# reaches other agents; permission.skill also denies it to everyone but @ops. Uses # reaches other agents; permission.skill also denies it to everyone but @ops. Uses the
# AGENT_TOKEN (an admin PAT during bootstrap) — see the script header for the token plan. # dedicated admin token TOKEN_OPS (the `ops` user), NOT AGENT_TOKEN — this step only writes
# the doc; the token itself is injected into the Run-agent step only when NAME==ops.
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
NAME: ${{ steps.prep.outputs.name }} NAME: ${{ steps.prep.outputs.name }}
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }} TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
run: bash "$SCRIPTS/skill-gitea-admin.sh" run: bash "$SCRIPTS/skill-gitea-admin.sh"
- name: Inspect / fetch image attachments (download only for vision agents) - name: Inspect / fetch image attachments (download only for vision agents)
@@ -168,6 +169,9 @@ jobs:
# It is already a required secret for the delegation step below; exposing it here too # It is already a required secret for the delegation step below; exposing it here too
# lets the agent process itself call the Gitea API on demand. # lets the agent process itself call the Gitea API on demand.
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }} AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
# The dedicated admin token for the `gitea-admin` skill — injected into the agent's process
# ONLY when it is @ops, so no other agent ever holds an admin credential in its env.
TOKEN_OPS: ${{ steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || '' }}
NAME: ${{ steps.prep.outputs.name }} NAME: ${{ steps.prep.outputs.name }}
MODEL: ${{ steps.prep.outputs.model }} MODEL: ${{ steps.prep.outputs.model }}
VISION: ${{ steps.prep.outputs.vision }} VISION: ${{ steps.prep.outputs.vision }}
+2 -2
View File
@@ -1,6 +1,6 @@
name: ai-agent name: ai-agent
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into # Standard caller for the shared AI-agent workflow (gitea/agents). Copy this file VERBATIM into
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in # any repo that should get the agents — it is identical in every repo. All logic + scripts live in
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is # agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level. # required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
@@ -11,5 +11,5 @@ on:
types: [opened] types: [opened]
jobs: jobs:
agent: agent:
uses: ffaerber/agents/.gitea/workflows/agent.yml@main uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit secrets: inherit
+29 -25
View File
@@ -3,18 +3,18 @@
# Emits an opencode Skill file under ~/.config/opencode/skills/ documenting how to create # Emits an opencode Skill file under ~/.config/opencode/skills/ documenting how to create
# orgs/users/repos, manage labels & secrets, and mint scoped per-user tokens via the Gitea API. # orgs/users/repos, manage labels & secrets, and mint scoped per-user tokens via the Gitea API.
# #
# The credential is AGENT_TOKEN (BOOTSTRAP: currently an admin PAT — temporary). This skill doc is # The credential is TOKEN_OPS (BOOTSTRAP: currently an admin PAT — temporary). This skill doc is
# written ONLY for @ops (gated on NAME) so the how-to never reaches other agents. NOTE: while # written ONLY for @ops (gated on NAME) so the how-to never reaches other agents. NOTE: while
# AGENT_TOKEN is admin, every agent's process technically holds an admin credential in its env — # TOKEN_OPS is admin, every agent's process technically holds an admin credential in its env —
# that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, AGENT_TOKEN should be # that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, TOKEN_OPS should be
# narrowed and a dedicated admin token injected only for @ops. # narrowed and a dedicated admin token injected only for @ops.
# #
# Required env (provided by the workflow step): NAME AGENT_TOKEN # Required env (provided by the workflow step): NAME TOKEN_OPS
set -eu set -eu
[ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; } [ "${NAME:-}" = "ops" ] || { echo "not @ops — skipping gitea-admin skill"; exit 0; }
if [ -z "${AGENT_TOKEN:-}" ]; then if [ -z "${TOKEN_OPS:-}" ]; then
echo "AGENT_TOKEN not set — skipping gitea-admin skill" echo "TOKEN_OPS not set — skipping gitea-admin skill"
exit 0 exit 0
fi fi
mkdir -p ~/.config/opencode/skills/gitea-admin && chmod 700 ~/.config/opencode/skills/gitea-admin mkdir -p ~/.config/opencode/skills/gitea-admin && chmod 700 ~/.config/opencode/skills/gitea-admin
@@ -29,7 +29,7 @@ tags: [gitea, admin, api, curl, bootstrap]
# `gitea-admin` Skill (operator / @ops only) # `gitea-admin` Skill (operator / @ops only)
Administer the Gitea instance via its REST API at `${GITHUB_SERVER_URL}/api/v1`, authenticated with Administer the Gitea instance via its REST API at `${GITHUB_SERVER_URL}/api/v1`, authenticated with
`Authorization: token ${AGENT_TOKEN}` (a site-admin token during bootstrap). Both env vars are `Authorization: token ${TOKEN_OPS}` (a site-admin token during bootstrap). Both env vars are
already set. Work from the issue instructions; report what you did. already set. Work from the issue instructions; report what you did.
## Golden rules ## Golden rules
@@ -42,7 +42,7 @@ already set. Work from the issue instructions; report what you did.
## Create an organisation ## Create an organisation
``` ```
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \
"$API/orgs" -d '{"username":"acme","visibility":"private"}' "$API/orgs" -d '{"username":"acme","visibility":"private"}'
``` ```
@@ -53,26 +53,30 @@ password you just set) to mint a scoped token, and store the token straight into
API="${GITHUB_SERVER_URL}/api/v1" API="${GITHUB_SERVER_URL}/api/v1"
PW=$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24) # generated, never printed PW=$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24) # generated, never printed
# 1) create the user # 1) create the user
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \
"$API/admin/users" -d "$(jq -nc --arg u inter --arg e inter@ffaerber.duckdns.org --arg p "$PW" \ "$API/admin/users" -d "$(jq -nc --arg u inter --arg e inter@ffaerber.duckdns.org --arg p "$PW" \
'{username:$u,email:$e,password:$p,must_change_password:false,source_id:0,visibility:"private"}')" '{username:$u,email:$e,password:$p,must_change_password:false,source_id:0,visibility:"private"}')"
# 2) mint a scoped token AS that user (pick the narrowest scopes needed) # 2) mint a scoped token AS that user (pick the narrowest scopes needed)
tok=$(curl -sS -u "inter:$PW" -H "Content-Type: application/json" -X POST "$API/users/inter/tokens" \ tok=$(curl -sS -u "inter:$PW" -H "Content-Type: application/json" -X POST "$API/users/inter/tokens" \
-d '{"name":"inter","scopes":["read:repository","write:issue"]}' | jq -r '.sha1') -d '{"name":"inter","scopes":["read:repository","write:issue"]}' | jq -r '.sha1')
# 3) store it as a secret (org / repo / user level) — never print $tok # 3) store the value in BOTH places (see "Secret storage" below) — never print $tok
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \
"$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')"
``` ```
Token **scopes** are groups of `read:`/`write:` on: `repository`, `issue`, `organization`, `user`, Token **scopes** are groups of `read:`/`write:` on: `repository`, `issue`, `organization`, `user`,
`package`, `notification`, `misc`, and (only for a privileged token) `admin`. `package`, `notification`, `misc`, and (only for a privileged token) `admin`.
## Token inventory — record everything in `gitea/secrets` ## Secret storage — `gitea/secrets/.env` is the SOURCE OF TRUTH
The private repo **`gitea/secrets`** (readable only by @ffaerber and @ops) is the source of truth for Every token/secret value MUST live in **`gitea/secrets/.env`** (private, readable only by @ffaerber and
tokens. Whenever you mint, rotate, or re-scope a token, append/update a row in its `tokens.md` via the @ops) as a `KEY=value` line. That file is the master; the workflows only get a secret because `.env` is
contents API (`GET` the file for its `sha`, then `PUT` the updated base64 content with that `sha`): mirrored into the org Actions secrets. So whenever you mint, rotate, or re-scope a token you MUST do
`| <token/secret name> | <owner user> | <scopes> | <Actions secret it is stored in> | <notes> |`. BOTH, in sync:
Storing the live value in the matching Actions secret is what workflows use; the `gitea/secrets` row 1. **`.env`**: `GET /repos/gitea/secrets/contents/.env` for its `sha`, add or replace the `KEY=value`
is the human-readable inventory. Never paste a token value into any issue/PR/comment/log. line, then `PUT` the updated base64 content with that `sha`.
2. **Actions secret**: `PUT /orgs/gitea/actions/secrets/{KEY}` with the same value (what runs use).
When you DELETE a token, remove it from BOTH. Keep `gitea/secrets/README.md` (the table describing what
each KEY is) up to date. Do NOT use `tokens.md` — the values live in `.env`. NEVER paste a token value
into any issue/PR/comment/log; it only ever goes into `.env` and the Actions secret.
## Change a user's token scope (the "update my token" flow) ## Change a user's token scope (the "update my token" flow)
Tokens are immutable — you can't edit scopes. Re-mint: delete the old token and create a new one, Tokens are immutable — you can't edit scopes. Re-mint: delete the old token and create a new one,
@@ -80,23 +84,23 @@ then overwrite the stored secret.
``` ```
curl -sS -u "inter:$PW" -X DELETE "$API/users/inter/tokens/<name-or-id>" # needs the password again curl -sS -u "inter:$PW" -X DELETE "$API/users/inter/tokens/<name-or-id>" # needs the password again
tok=$(curl -sS -u "inter:$PW" -X POST "$API/users/inter/tokens" -d '{"name":"inter","scopes":[…new…]}' | jq -r '.sha1') tok=$(curl -sS -u "inter:$PW" -X POST "$API/users/inter/tokens" -d '{"name":"inter","scopes":[…new…]}' | jq -r '.sha1')
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')" curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')"
``` ```
(If you no longer hold the user's password, reset it first via `PATCH /admin/users/{username}` with a (If you no longer hold the user's password, reset it first via `PATCH /admin/users/{username}` with a
new generated password, then re-mint.) new generated password, then re-mint.)
## Actions secrets & variables ## Actions secrets & variables
``` ```
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}' curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}' curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $AGENT_TOKEN" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level
``` ```
## Labels (repo or org-wide). Scoped labels (name `scope/value`) are mutually exclusive if `exclusive:true`. ## Labels (repo or org-wide). Scoped labels (name `scope/value`) are mutually exclusive if `exclusive:true`.
``` ```
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$API/repos/{owner}/{repo}/labels" \ curl -sS -X POST -H "Authorization: token $TOKEN_OPS" "$API/repos/{owner}/{repo}/labels" \
-d '{"name":"status/review","color":"1d76db","description":"…","exclusive":true}' -d '{"name":"status/review","color":"1d76db","description":"…","exclusive":true}'
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/labels" -d '{…}' curl -sS -X POST -H "Authorization: token $TOKEN_OPS" "$API/orgs/{org}/labels" -d '{…}'
``` ```
## Bootstrap a new repo (create + wire it up for the agents) ## Bootstrap a new repo (create + wire it up for the agents)
@@ -105,7 +109,7 @@ curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" "$API/orgs/{org}/labels"
3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml` 3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml`
with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README). with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README).
4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`). 4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`).
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, AGENT_TOKEN, 5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, TOKEN_OPS,
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above. TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
## Admin user management ## Admin user management
+2 -2
View File
@@ -37,7 +37,7 @@ it is the source of truth, and `agents` itself uses the same file:
```yaml ```yaml
name: ai-agent name: ai-agent
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into # Standard caller for the shared AI-agent workflow (gitea/agents). Copy this file VERBATIM into
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in # any repo that should get the agents — it is identical in every repo. All logic + scripts live in
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is # agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level. # required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
@@ -49,7 +49,7 @@ on:
types: [opened] types: [opened]
jobs: jobs:
agent: agent:
uses: ffaerber/agents/.gitea/workflows/agent.yml@main uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit secrets: inherit
``` ```