Compare commits

...
Author SHA1 Message Date
ffaerber 269e932b19 Merge pull request 'agents: caller-provided skills hook (move node1-ssh to the owning repo)' (#65) from feat/caller-provided-skills into main 2026-07-05 16:37:12 +02:00
Felix FaerberandClaude Opus 4.8 0f8893330f agents: caller-provided skills hook — repos can ship their own opencode skills
A consuming repo can now add repo-specific skills under .gitea/agent-skills/<name>/
(SKILL.md + skill.json + optional setup.sh); the framework installs the ones
allowed for the running agent. This keeps deploy-target / infra specifics in the
repo they belong to instead of hardcoded in the shared workflow.

- install-caller-skills.sh: scans the caller workspace, installs each skill whose
  skill.json `agents` list includes the running agent, runs its optional setup.sh
  with all inherited secrets available as $SECRETS_JSON (toJSON(secrets) — so a
  caller's setup can read repo-specific secret names the framework can't know),
  and merges the allowed skills into the permission.skill allow-list.
- agent.yml: replace the hardcoded node1-ssh step with the generic caller-skills
  step (passes NAME, WORKSPACE, SECRETS_JSON).
- Remove the built-in node1-ssh skill: delete skill-node1-ssh.sh, drop "node1-ssh"
  from agents.json (senior/lead). The homelab repo now owns that skill.
- run-agent.sh: note caller-provided skills aren't in the roster (route them via
  the caller's AGENTS.md).

toJSON(secrets) verified supported on this Gitea (1.27) via an isolated probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:35:45 +03:00
ffaerber 63dbd2727f Merge pull request 'agents: remove shared AGENT_TOKEN — per-agent tokens only' (#63) from chore/remove-agent-token into main 2026-07-05 15:51:37 +02:00
Felix FaerberandClaude Opus 4.8 3c66220f6d agents: remove shared AGENT_TOKEN fallback — per-agent tokens only
All three consuming repos (gitea/agents, gitea/ops, ffaerber/homelab) now
carry the per-agent TOKEN_* secrets (org-level for gitea/*, user-level for
ffaerber/*), so the shared AGENT_TOKEN fallback is no longer needed.

- agent.yml: SELF_TOKEN ternary ends in '' instead of secrets.AGENT_TOKEN;
  removed AGENT_TOKEN from the publish + rescue step env.
- publish.sh: TTOK is just the agent's own token; if an agent somehow has none
  (TOK fell back to the built-in GT), TTOK is left empty so the trigger/merge
  is skipped rather than silently no-op'ing under the built-in token.
- README: drop the AGENT_TOKEN row; state that every consuming repo must carry
  the per-agent TOKEN_* (no shared fallback).

The AGENT_TOKEN Actions secrets (org + user) and its gitea/secrets/.env entry
are removed separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:51:06 +03:00
ffaerber c792228e2c Merge pull request 'docs: README reflects per-agent SELF_TOKEN model' (#62) from docs/token-model-selftoken into main 2026-07-05 15:40:29 +02:00
Felix FaerberandClaude Opus 4.8 1c4e4ce950 docs: README reflects per-agent SELF_TOKEN model
The secret table still described AGENT_TOKEN as primary and TOKEN_* as
optional "falls back to the bot". The per-agent-token refactor inverted that:
each agent's own TOKEN_* is primary (selected into SELF_TOKEN), AGENT_TOKEN is
now only the fallback for repos without per-agent tokens. Document TOKEN_OPS,
the SELF_TOKEN selection, and that TOKEN_QA needs write:repository to merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:40:01 +03:00
ffaerber 738848304e Merge pull request 'agents: per-agent Gitea identity — each agent uses its own token' (#59) from feat/per-agent-tokens into main 2026-07-05 15:33:54 +02:00
Felix FaerberandClaude Opus 4.8 06f1924441 agents: per-agent Gitea identity — each agent uses its own token
Drop the shared AGENT_TOKEN as the primary credential; every agent now acts
as its own Gitea user (TOKEN_PM for @pm, TOKEN_OPS for @ops, …) for API
calls, delegation/autopilot trigger comments, and PR merges.

- agent.yml: Run-agent step injects SELF_TOKEN — a ternary selecting the
  running agent's own token by name, falling back to AGENT_TOKEN for repos
  not yet migrated to per-agent tokens (e.g. homelab). Only that one token
  enters the agent process, so no agent can act as another. The gitea-api /
  gitea-admin skill-setup steps no longer carry a token (they only write docs).
- Gate: trust the agent roster (pm/junior/senior/lead/qa/ops) as comment
  authors so an agent's own delegation/autopilot trigger comment (posted with
  its PAT, no 🤖 prefix) fires the next run. @ops added to the mention set.
- publish.sh: TOK = agent identity (comments/replies); new TTOK = trigger/merge
  token (agent PAT, else AGENT_TOKEN fallback) for delegation, autopilot @qa
  triggers, and PR merges that must fire downstream workflows.
- skill-gitea-api.sh / skill-gitea-admin.sh / run-agent.sh: AGENT_TOKEN/
  TOKEN_OPS → SELF_TOKEN in the emitted skill docs and env contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:20:29 +03:00
ffaerber e53e5caf8c Merge pull request 'feat(ops): @ops uses dedicated TOKEN_OPS not AGENT_TOKEN' (#58) from feat/ops-dedicated-token into main 2026-07-05 14:52:09 +02:00
9 changed files with 215 additions and 190 deletions
+33 -35
View File
@@ -12,13 +12,21 @@ jobs:
# defense against malicious-issue prompt injection — do not loosen it. # defense against malicious-issue prompt injection — do not loosen it.
if: > if: >
(github.event.comment == null && github.event.issue.user.login == 'ffaerber') || (github.event.comment == null && github.event.issue.user.login == 'ffaerber') ||
(github.event.comment != null && github.event.comment.user.login == 'ffaerber' && (github.event.comment != null &&
(github.event.comment.user.login == 'ffaerber' ||
github.event.comment.user.login == 'pm' ||
github.event.comment.user.login == 'junior' ||
github.event.comment.user.login == 'senior' ||
github.event.comment.user.login == 'lead' ||
github.event.comment.user.login == 'qa' ||
github.event.comment.user.login == 'ops') &&
!contains(github.event.comment.body, '🤖') && !contains(github.event.comment.body, '🤖') &&
(contains(github.event.comment.body, '@pm') || (contains(github.event.comment.body, '@pm') ||
contains(github.event.comment.body, '@junior') || contains(github.event.comment.body, '@junior') ||
contains(github.event.comment.body, '@senior') || contains(github.event.comment.body, '@senior') ||
contains(github.event.comment.body, '@lead') || contains(github.event.comment.body, '@lead') ||
contains(github.event.comment.body, '@qa'))) contains(github.event.comment.body, '@qa') ||
contains(github.event.comment.body, '@ops')))
runs-on: ci-runner runs-on: ci-runner
steps: steps:
- name: Acknowledge with 👀 - name: Acknowledge with 👀
@@ -104,44 +112,36 @@ jobs:
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
run: bash "$SCRIPTS/install-opencode.sh" run: bash "$SCRIPTS/install-opencode.sh"
- name: Set up read-only SSH alias `node1` (+ opencode skill so the agent actually knows about it) - name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/)
# 1) Writes the deploy key + an SSH config alias so the agent can run # Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under
# `ssh node1 <read-only cmd>` (matches the homelab opencode.json allowlist). # `.gitea/agent-skills/<name>/` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's
# 2) Emits a `node1-ssh` opencode Skill file under ~/.config/opencode/skills/ so any # "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so
# downstream repo's dev agent discovers this capability via OpenCode's skill registry # deploy-target / infra specifics live in the repo they belong to, not in this framework.
# rather than having to trial against the permission allowlist. Only emitted when the # SECRETS_JSON = toJSON(secrets): a caller's setup.sh reads the repo-specific secrets it needs
# swarm plumbing is actually wired for that caller (SWARM_HOST/SWARM_USER/SSH_PRIV_KEY). # (whose names this framework can't know) via jq; it never touches disk here in the clear.
# All three secrets are passed via env and never inlined into shell — this shared workflow
# runs in repos that don't have them and must not fail there.
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
SWARM_HOST: ${{ secrets.SWARM_HOST }} NAME: ${{ steps.prep.outputs.name }}
SWARM_USER: ${{ secrets.SWARM_USER }} WORKSPACE: ${{ github.workspace }}
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }} SECRETS_JSON: ${{ toJSON(secrets) }}
run: bash "$SCRIPTS/skill-node1-ssh.sh" run: bash "$SCRIPTS/install-caller-skills.sh"
- name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos) - name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos)
# Mirrors the node1-ssh pattern: emit an opencode Skill file under # Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token
# ~/.config/opencode/skills/ so any dev agent discovers the capability via OpenCode's # (e.g. TOKEN_PM for @pm), injected into the Run-agent step below — so each agent talks to
# skill registry. The credential is the shared AGENT_TOKEN (a PAT whose scopes the # Gitea as itself. This step only writes the doc; permission.skill scopes who may load it.
# maintainer set at creation time — issue/repository/organization/misc read+write, cross-repo).
# Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a
# broken skill. The token is passed via env and never inlined into shell.
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
run: bash "$SCRIPTS/skill-gitea-api.sh" run: bash "$SCRIPTS/skill-gitea-api.sh"
- 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) and permission.skill also denies
# reaches other agents; permission.skill also denies it to everyone but @ops. Uses the # it to every other agent. It uses SELF_TOKEN (which for @ops is TOKEN_OPS), injected into the
# dedicated admin token TOKEN_OPS (the `ops` user), NOT AGENT_TOKEN — this step only writes # Run-agent step. This step only writes the doc.
# 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 }}
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)
@@ -165,13 +165,12 @@ jobs:
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# AGENT_TOKEN powers the `gitea-api` skill (cross-repo issue/PR/Actions read+write). # SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …).
# It is already a required secret for the delegation step below; exposing it here too # Only this agent's token is placed in its process env, so no agent can act as another.
# lets the agent process itself call the Gitea API on demand. # Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }} # consuming repo now carries the per-agent TOKEN_* secrets (org-level for gitea/*, user-level
# The dedicated admin token for the `gitea-admin` skill — injected into the agent's process # for ffaerber/*), so there is no shared-token fallback.
# ONLY when it is @ops, so no other agent ever holds an admin credential in its env. SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || '' }}
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 }}
@@ -197,7 +196,6 @@ jobs:
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
GT: ${{ secrets.GITEA_TOKEN }} GT: ${{ secrets.GITEA_TOKEN }}
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
TOKEN_PM: ${{ secrets.TOKEN_PM }} TOKEN_PM: ${{ secrets.TOKEN_PM }}
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }} TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }} TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
+52 -6
View File
@@ -1,8 +1,54 @@
{ {
"pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."}, "pm": {
"junior": {"model":"ollama-cloud/kimi-k2.7-code:cloud","vision":false,"mode":"pr", "skills":[],"desc":"Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."}, "model": "ollama-cloud/gemma4:cloud",
"senior": {"model":"ollama-cloud/glm-5.2:cloud","vision":false,"mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."}, "vision": true,
"lead": {"model":"anthropic/claude-opus-4-8","vision":true, "mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Tech lead — the hardest problems, architecture, and final calls."}, "mode": "comment",
"qa": {"model":"ollama-cloud/minimax-m3:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."}, "skills": [
"ops": {"model":"anthropic/claude-opus-4-8","vision":false,"mode":"comment","skills":["gitea-admin"],"desc":"Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."} "gitea-api"
],
"desc": "Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."
},
"junior": {
"model": "ollama-cloud/kimi-k2.7-code:cloud",
"vision": false,
"mode": "pr",
"skills": [],
"desc": "Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."
},
"senior": {
"model": "ollama-cloud/glm-5.2:cloud",
"vision": false,
"mode": "pr",
"skills": [
"gitea-api"
],
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
},
"lead": {
"model": "anthropic/claude-opus-4-8",
"vision": true,
"mode": "pr",
"skills": [
"gitea-api"
],
"desc": "Tech lead — the hardest problems, architecture, and final calls."
},
"qa": {
"model": "ollama-cloud/minimax-m3:cloud",
"vision": true,
"mode": "comment",
"skills": [
"gitea-api"
],
"desc": "QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."
},
"ops": {
"model": "anthropic/claude-opus-4-8",
"vision": false,
"mode": "comment",
"skills": [
"gitea-admin"
],
"desc": "Gitea operator — administers the Gitea instance itself: create orgs/users/repos, manage labels and secrets, mint scoped per-user tokens, bootstrap new repos with the agent caller. Comments only; never edits code. ALWAYS confirms before any destructive action (delete user/repo/org)."
}
} }
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Install CALLER-PROVIDED opencode skills — the framework's skill-plugin hook.
#
# The reusable workflow ships a few built-in skills (gitea-api, gitea-admin). A consuming repo can
# add its OWN, repo-specific skills (e.g. a homelab "ssh into the deploy host" skill) without any
# change to this framework: it commits them under `.gitea/agent-skills/<name>/` in its own repo.
# This step discovers them in the checked-out caller workspace and installs the ones allowed for the
# running agent. That keeps deploy-target / infra specifics in the repo they belong to, not here.
#
# Layout the framework expects, per skill, in the CALLER repo:
# .gitea/agent-skills/<name>/
# SKILL.md (required) — the opencode Skill doc; copied verbatim into the skill registry.
# skill.json (required) — {"agents":["senior","lead"]} — which agents may load this skill.
# setup.sh (optional) — runtime setup (e.g. write an SSH alias). Runs ONLY when this agent is
# allowed the skill. Receives $SECRETS_JSON (all inherited secrets, as JSON) and must
# extract what it needs via jq; it must no-op cleanly if its secrets aren't set.
#
# Required env (provided by the workflow step): NAME WORKSPACE SECRETS_JSON
# (SECRETS_JSON = toJSON(secrets); passed so a caller's setup.sh can read repo-specific secrets
# whose names this framework cannot know in advance.)
set -eu
DIR="${WORKSPACE:-$GITHUB_WORKSPACE}/.gitea/agent-skills"
CFG="$HOME/.config/opencode/opencode.json"
[ -d "$DIR" ] || { echo "no caller skills (.gitea/agent-skills/ absent) — nothing to install"; exit 0; }
allow='{}' # skills to flip to "allow" in permission.skill for THIS agent
for skill_dir in "$DIR"/*/; do
[ -d "$skill_dir" ] || continue
name=$(basename "$skill_dir")
md="$skill_dir/SKILL.md"; meta="$skill_dir/skill.json"
if [ ! -f "$md" ] || [ ! -f "$meta" ]; then
echo "caller skill '$name': missing SKILL.md or skill.json — skipping"; continue
fi
# Is this agent allowed the skill?
if ! jq -e --arg n "$NAME" '(.agents // []) | index($n)' "$meta" >/dev/null 2>&1; then
echo "caller skill '$name': not allowed for @$NAME — skipping"; continue
fi
# Install the doc.
dest="$HOME/.config/opencode/skills/$name"
mkdir -p "$dest" && chmod 700 "$dest"
cp "$md" "$dest/SKILL.md"
chmod -R o=rX "$dest"
# Optional runtime setup, with all inherited secrets available as JSON (never printed here).
if [ -f "$skill_dir/setup.sh" ]; then
echo "caller skill '$name': running setup.sh for @$NAME"
SECRETS_JSON="${SECRETS_JSON:-{}}" NAME="$NAME" WORKSPACE="${WORKSPACE:-$GITHUB_WORKSPACE}" \
bash "$skill_dir/setup.sh" || { echo "caller skill '$name': setup.sh failed — skipping this skill"; continue; }
fi
allow=$(jq -nc --argjson a "$allow" --arg n "$name" '$a + {($n):"allow"}')
echo "caller skill '$name': installed + allowed for @$NAME"
done
# Merge the allowed caller skills into the permission allow-list opencode already wrote.
if [ "$allow" != '{}' ] && [ -f "$CFG" ]; then
tmp=$(mktemp)
jq --argjson add "$allow" '.permission.skill = ((.permission.skill // {}) + $add)' "$CFG" > "$tmp" && mv "$tmp" "$CFG"
echo "permission.skill updated with caller skills: $(jq -c '.permission.skill' "$CFG")"
fi
+20 -13
View File
@@ -2,7 +2,7 @@
# Publish — PR (dev agents) or comment (pm/qa), always reply in the issue. # Publish — PR (dev agents) or comment (pm/qa), always reply in the issue.
# #
# Required env (provided by the workflow step): # Required env (provided by the workflow step):
# GT AGENT_TOKEN TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA # GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
# NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY # NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY
# IS_PR AUTOPILOT ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR) # IS_PR AUTOPILOT ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR)
set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step
@@ -12,6 +12,13 @@ case "$NAME" in
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";; lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; ops) TOK="$TOKEN_OPS";; *) TOK="";;
esac esac
[ -z "$TOK" ] && TOK="$GT" [ -z "$TOK" ] && TOK="$GT"
# Trigger token: comments that must FIRE the next workflow (delegation, autopilot) and PR merges
# cannot use the built-in GITEA_TOKEN (Gitea won't start new runs from it) — they need a real PAT.
# Every agent now has its own token, so TTOK is just the agent's token. If an agent somehow has none
# (TOK fell back to the built-in GT), TTOK is left empty so the trigger/merge is skipped rather than
# silently no-op'ing under the built-in token.
TTOK="$TOK"
[ "$TTOK" = "$GT" ] && TTOK=""
git config user.name "$NAME" git config user.name "$NAME"
git config user.email "$NAME@ffaerber.duckdns.org" git config user.email "$NAME@ffaerber.duckdns.org"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}" API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
@@ -120,11 +127,11 @@ if [ "$MODE" != "pr" ]; then
echo "MERGE_PR marker but this run is not on a PR thread — skipping merge" echo "MERGE_PR marker but this run is not on a PR thread — skipping merge"
else else
echo "@qa autopilot: merging PR #$NUM (origin issue #${ISSNUM:-$NUM})" echo "@qa autopilot: merging PR #$NUM (origin issue #${ISSNUM:-$NUM})"
# Merge with AGENT_TOKEN (a PAT) — NOT the built-in Actions token — so the resulting push to # Merge with TOK (a PAT) — NOT the built-in Actions token — so the resulting push to
# main TRIGGERS downstream workflows (e.g. deploy). A merge made with the built-in GITEA_TOKEN # main TRIGGERS downstream workflows (e.g. deploy). A merge made with the built-in GITEA_TOKEN
# does not fire new runs (loop-prevention), which silently skips the deploy. Fall back to the # does not fire new runs (loop-prevention), which silently skips the deploy. Fall back to the
# agent's own token only if AGENT_TOKEN isn't set (then the deploy would need a manual run). # agent's own token only if TOK isn't set (then the deploy would need a manual run).
mtok="${AGENT_TOKEN:-$TOK}" mtok="$TTOK"
mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \ mc=$(curl -sS -o /tmp/merge_resp.txt -w '%{http_code}' -X POST \
-H "Authorization: token $mtok" -H "Content-Type: application/json" \ -H "Authorization: token $mtok" -H "Content-Type: application/json" \
"$API/pulls/$NUM/merge" -d '{"Do":"merge"}') "$API/pulls/$NUM/merge" -d '{"Do":"merge"}')
@@ -164,7 +171,7 @@ if [ "$MODE" != "pr" ]; then
else else
n=$((prior + 1)) n=$((prior + 1))
echo "@qa autopilot: bounce $n/3 -> @$target" echo "@qa autopilot: bounce $n/3 -> @$target"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$NUM/comments" \ "$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "@$target please address @qa's feedback above and update this PR (autopilot fix attempt $n/3)." '{body:$b}')" \ -d "$(jq -nc --arg b "@$target please address @qa's feedback above and update this PR (autopilot fix attempt $n/3)." '{body:$b}')" \
-w '\nbounce -> HTTP %{http_code}\n' || true -w '\nbounce -> HTTP %{http_code}\n' || true
@@ -178,17 +185,17 @@ if [ "$MODE" != "pr" ]; then
exit 0 exit 0
fi fi
# Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it # Auto-delegate: if the plan names a teammate, trigger them via TOK (a PAT, so it
# fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the # fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the
# chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops. # chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops.
if [ -n "$AGENT_TOKEN" ]; then if [ -n "$TTOK" ]; then
# Only delegate on an explicit "DELEGATE: @<agent>" line — never on a prose mention, # Only delegate on an explicit "DELEGATE: @<agent>" line — never on a prose mention,
# so an agent that is asking the maintainer a question does not hand off prematurely. # so an agent that is asking the maintainer a question does not hand off prematurely.
target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null \ target=$(grep -oiE 'DELEGATE:[[:space:]]*@(junior|senior|lead|qa)' /tmp/agent_out.md 2>/dev/null \
| head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]') | head -1 | grep -oiE '(junior|senior|lead|qa)' | tr '[:upper:]' '[:lower:]')
if [ -n "$target" ] && [ "$target" != "$NAME" ]; then if [ -n "$target" ] && [ "$target" != "$NAME" ]; then
echo "auto-delegating to @$target" echo "auto-delegating to @$target"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$NUM/comments" \ "$API/issues/$NUM/comments" \
-d "$(jq -nc --arg b "@$target please proceed with issue #$NUM per the plan above (delegated by $NAME)." '{body:$b}')" \ -d "$(jq -nc --arg b "@$target please proceed with issue #$NUM per the plan above (delegated by $NAME)." '{body:$b}')" \
-w '\ndelegate -> HTTP %{http_code}\n' || true -w '\ndelegate -> HTTP %{http_code}\n' || true
@@ -272,13 +279,13 @@ prpost() {
if [ "$NEW" = "true" ]; then if [ "$NEW" = "true" ]; then
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$NAME" "$url" "$activity")" prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$NAME" "$url" "$activity")"
# AUTOPILOT: hand the fresh PR to @qa automatically (via AGENT_TOKEN, so it fires a new run). # AUTOPILOT: hand the fresh PR to @qa automatically (via TOK, so it fires a new run).
# @qa then verifies and — if green — merges + closes via its MERGE_PR marker. The comment lands # @qa then verifies and — if green — merges + closes via its MERGE_PR marker. The comment lands
# on the PR thread ($prnum) so the next run resolves the origin issue's label from the branch # on the PR thread ($prnum) so the next run resolves the origin issue's label from the branch
# name. The '🤖' guard on the trigger gate stops status-comment loops. # name. The '🤖' guard on the trigger gate stops status-comment loops.
if [ "$AUTOPILOT" = "true" ] && [ -n "$AGENT_TOKEN" ] && [ -n "$prnum" ]; then if [ "$AUTOPILOT" = "true" ] && [ -n "$TTOK" ] && [ -n "$prnum" ]; then
echo "autopilot: auto-triggering @qa to review PR #$prnum" echo "autopilot: auto-triggering @qa to review PR #$prnum"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$prnum/comments" \ "$API/issues/$prnum/comments" \
-d "$(jq -nc --arg b "@qa please verify this PR (autopilot: issue #$NUM is labeled autopilot). Merge it if correct, or bounce it back to the dev with exactly what needs fixing." '{body:$b}')" \ -d "$(jq -nc --arg b "@qa please verify this PR (autopilot: issue #$NUM is labeled autopilot). Merge it if correct, or bounce it back to the dev with exactly what needs fixing." '{body:$b}')" \
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true -w '\ntrigger-qa -> HTTP %{http_code}\n' || true
@@ -288,11 +295,11 @@ else
# repeat the full write-up in the comment (the reasoning trail below shows what this run did). # repeat the full write-up in the comment (the reasoning trail below shows what this run did).
prpost "$prnum" "$(printf '🤖 **@%s** — pushed an update to the PR:\n- %s%s' "$NAME" "$url" "$activity")" prpost "$prnum" "$(printf '🤖 **@%s** — pushed an update to the PR:\n- %s%s' "$NAME" "$url" "$activity")"
# AUTOPILOT: after a dev pushes a fix (e.g. following a @qa bounce), hand back to @qa to re-verify. # AUTOPILOT: after a dev pushes a fix (e.g. following a @qa bounce), hand back to @qa to re-verify.
if [ "$AUTOPILOT" = "true" ] && [ -n "$AGENT_TOKEN" ] && [ -n "$prnum" ]; then if [ "$AUTOPILOT" = "true" ] && [ -n "$TTOK" ] && [ -n "$prnum" ]; then
case "$NAME" in case "$NAME" in
junior|senior|lead) junior|senior|lead)
echo "autopilot: dev pushed a fix — re-triggering @qa to re-verify PR #$prnum" echo "autopilot: dev pushed a fix — re-triggering @qa to re-verify PR #$prnum"
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $TTOK" -H "Content-Type: application/json" \
"$API/issues/$prnum/comments" \ "$API/issues/$prnum/comments" \
-d "$(jq -nc --arg b "@qa please re-verify this PR (autopilot). Merge it if now correct, or bounce it back with exactly what still needs fixing." '{body:$b}')" \ -d "$(jq -nc --arg b "@qa please re-verify this PR (autopilot). Merge it if now correct, or bounce it back with exactly what still needs fixing." '{body:$b}')" \
-w '\ntrigger-qa -> HTTP %{http_code}\n' || true -w '\ntrigger-qa -> HTTP %{http_code}\n' || true
+5 -4
View File
@@ -3,7 +3,7 @@
# plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl). # plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl).
# #
# Required env (provided by the workflow step): # Required env (provided by the workflow step):
# ANTHROPIC_API_KEY AGENT_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE # ANTHROPIC_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
# IBODY CMT # IBODY CMT
# FILES (the opencode -f image flags, from the imgs step output) # FILES (the opencode -f image flags, from the imgs step output)
# AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode). # AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode).
@@ -12,9 +12,10 @@ set -u
[ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)" [ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)"
THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)" THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)"
DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json) DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json)
# Include each teammate's skills so an agent (esp. @pm) can route by capability — e.g. only # Include each teammate's registry skills so an agent (esp. @pm) can route by capability — e.g. only
# @senior/@lead hold node1-ssh, so a node1 task must not go to @junior. Skill *names* only; the # skill-holders should get a task that needs that skill. Skill *names* only; the scoped how-to detail
# scoped how-to detail stays hidden per the permission.skill allow-list. # stays hidden per the permission.skill allow-list. (Caller-provided skills from a repo's
# .gitea/agent-skills/ are not in this roster — document that routing in the caller's AGENTS.md.)
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json) ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json)
if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi
NOTE="" NOTE=""
+17 -19
View File
@@ -3,20 +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 TOKEN_OPS (BOOTSTRAP: currently an admin PAT — temporary). This skill doc is # The credential is SELF_TOKEN (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
# TOKEN_OPS is admin, every agent's process technically holds an admin credential in its env — # SELF_TOKEN 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, TOKEN_OPS should be # that is the bootstrap trade-off. Once @ops is minting scoped per-user tokens, SELF_TOKEN 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 TOKEN_OPS # Required env (provided by the workflow step): NAME SELF_TOKEN
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 "${TOKEN_OPS:-}" ]; then # The doc references $SELF_TOKEN (@ops's own admin token, present in the Run-agent step). This step
echo "TOKEN_OPS not set — skipping gitea-admin skill" # only writes the doc for @ops; permission.skill also denies the skill to every other agent.
exit 0
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
cat > ~/.config/opencode/skills/gitea-admin/SKILL.md <<'SKILLET' cat > ~/.config/opencode/skills/gitea-admin/SKILL.md <<'SKILLET'
--- ---
@@ -29,7 +27,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 ${TOKEN_OPS}` (a site-admin token during bootstrap). Both env vars are `Authorization: token ${SELF_TOKEN}` (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 +40,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 $TOKEN_OPS" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/orgs" -d '{"username":"acme","visibility":"private"}' "$API/orgs" -d '{"username":"acme","visibility":"private"}'
``` ```
@@ -53,14 +51,14 @@ 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 $TOKEN_OPS" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -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 the value in BOTH places (see "Secret storage" below) — never print $tok # 3) store the value in BOTH places (see "Secret storage" below) — never print $tok
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" -H "Content-Type: application/json" \ curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" -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`,
@@ -84,23 +82,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 $TOKEN_OPS" "$API/orgs/gitea/actions/secrets/TOKEN_INTER" -d "$(jq -nc --arg d "$tok" '{data:$d}')" curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$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 $TOKEN_OPS" "$API/orgs/{org}/actions/secrets/{NAME}" -d '{"data":"<value>"}' curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$API/orgs/{org}/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 $SELF_TOKEN" "$API/repos/{owner}/{repo}/actions/secrets/{NAME}" -d '{"data":"<value>"}'
curl -sS -X PUT -H "Authorization: token $TOKEN_OPS" "$API/user/actions/secrets/{NAME}" -d '{"data":"<value>"}' # user-level curl -sS -X PUT -H "Authorization: token $SELF_TOKEN" "$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 $TOKEN_OPS" "$API/repos/{owner}/{repo}/labels" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$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 $TOKEN_OPS" "$API/orgs/{org}/labels" -d '{…}' curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$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)
@@ -109,7 +107,7 @@ curl -sS -X POST -H "Authorization: token $TOKEN_OPS" "$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, TOKEN_OPS, 5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, SELF_TOKEN,
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
+19 -25
View File
@@ -1,19 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos). # Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos).
# Mirrors the node1-ssh pattern: emit an opencode Skill file under # Emits an opencode Skill file under ~/.config/opencode/skills/. The credential is SELF_TOKEN — the
# ~/.config/opencode/skills/ so any dev agent discovers the capability via OpenCode's # RUNNING agent's OWN token (e.g. TOKEN_PM for @pm), present in the Run-agent step's env. So each
# skill registry. The credential is the shared AGENT_TOKEN (a PAT whose scopes the # agent talks to Gitea as itself, with its own scopes. This step only writes the doc, so it always
# maintainer set at creation time — issue/repository/organization/misc read+write, cross-repo). # emits; permission.skill decides which agents may actually load it.
# Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a
# broken skill. The token is passed via env and never inlined into shell.
# #
# Required env (provided by the workflow step): AGENT_TOKEN # Required env (provided by the workflow step): (none — the token is in the Run-agent step)
set -eu set -eu
if [ -z "$AGENT_TOKEN" ]; then
echo "AGENT_TOKEN not set — skipping gitea-api skill"
exit 0
fi
mkdir -p ~/.config/opencode/skills/gitea-api && chmod 700 ~/.config/opencode/skills/gitea-api mkdir -p ~/.config/opencode/skills/gitea-api && chmod 700 ~/.config/opencode/skills/gitea-api
cat > ~/.config/opencode/skills/gitea-api/SKILL.md <<'SKILLET' cat > ~/.config/opencode/skills/gitea-api/SKILL.md <<'SKILLET'
--- ---
@@ -33,14 +27,14 @@ Use this skill to talk to the **Gitea REST API** (`${GITHUB_SERVER_URL}/api/v1`)
## How it works ## How it works
Calls go via `curl` with the header `Authorization: token ${AGENT_TOKEN}`. Both Calls go via `curl` with the header `Authorization: token ${SELF_TOKEN}`. Both
`${GITHUB_SERVER_URL}` (the instance root, e.g. `https://git.example.com`) and `${GITHUB_SERVER_URL}` (the instance root, e.g. `https://git.example.com`) and
`${AGENT_TOKEN}` are present in your environment. The API root is `${SELF_TOKEN}` are present in your environment. The API root is
`${GITHUB_SERVER_URL}/api/v1`. `${GITHUB_SERVER_URL}/api/v1`.
## What you're actually allowed to do — the token's scopes are the source of truth ## What you're actually allowed to do — the token's scopes are the source of truth
The shared `AGENT_TOKEN` was granted **read and write** on the `issue`, The shared `SELF_TOKEN` was granted **read and write** on the `issue`,
`repository`, `organization`, and `misc` scope groups, **cross-repo** (any repo the `repository`, `organization`, and `misc` scope groups, **cross-repo** (any repo the
token's account can see). That covers: token's account can see). That covers:
- issues, PRs, comments, labels, milestones, reviewers (read + write) - issues, PRs, comments, labels, milestones, reviewers (read + write)
@@ -64,9 +58,9 @@ in `agent.yml` exists to enforce.
## Never echo the token ## Never echo the token
**Never print, log, or exfiltrate `AGENT_TOKEN`.** Do not pass it to `echo`, do not **Never print, log, or exfiltrate `SELF_TOKEN`.** Do not pass it to `echo`, do not
include it in a comment, do not write it to a file. If you need to show a curl command, include it in a comment, do not write it to a file. If you need to show a curl command,
redact the header as `Authorization: token $AGENT_TOKEN`. redact the header as `Authorization: token $SELF_TOKEN`.
## Examples ## Examples
@@ -77,9 +71,9 @@ All examples assume `API="${GITHUB_SERVER_URL}/api/v1"`.
```bash ```bash
API="${GITHUB_SERVER_URL}/api/v1" API="${GITHUB_SERVER_URL}/api/v1"
# Get issue/PR #12 on repo owner/repo (a PR if the number is a pull; issues/PRs share one number space) # Get issue/PR #12 on repo owner/repo (a PR if the number is a pull; issues/PRs share one number space)
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/issues/12" | jq '{title,state,body,user:.user.login}' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/issues/12" | jq '{title,state,body,user:.user.login}'
# Its comment thread # Its comment thread
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/issues/12/comments?limit=100" \ curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/issues/12/comments?limit=100" \
| jq -r '.[] | "### @\(.user.login):\n\(.body)\n"' | jq -r '.[] | "### @\(.user.login):\n\(.body)\n"'
``` ```
@@ -91,27 +85,27 @@ find the owner/repo for a `#N` in *this* repo, just use `${GITHUB_REPOSITORY}`.
```bash ```bash
API="${GITHUB_SERVER_URL}/api/v1" API="${GITHUB_SERVER_URL}/api/v1"
# Recent runs on a repo # Recent runs on a repo
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/actions/runs?limit=10" | jq '.[] | {id,status,conclusion,head_branch,event}' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/actions/runs?limit=10" | jq '.[] | {id,status,conclusion,head_branch,event}'
# Jobs for a run # Jobs for a run
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/actions/runs/$RUN_ID/jobs" | jq '.[] | {name,status,conclusion}' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/actions/runs/$RUN_ID/jobs" | jq '.[] | {name,status,conclusion}'
# Logs for a job (returns a text/plain stream) # Logs for a job (returns a text/plain stream)
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/repos/owner/repo/actions/jobs/$JOB_ID/logs" curl -sS -H "Authorization: token $SELF_TOKEN" "$API/repos/owner/repo/actions/jobs/$JOB_ID/logs"
``` ```
### List repos across an org ### List repos across an org
```bash ```bash
curl -sS -H "Authorization: token $AGENT_TOKEN" "$API/orgs/$ORG/repos?limit=50" | jq '.[] | .full_name' curl -sS -H "Authorization: token $SELF_TOKEN" "$API/orgs/$ORG/repos?limit=50" | jq '.[] | .full_name'
``` ```
### Write: comment / label / close on another repo's issue (only when your task requires it) ### Write: comment / label / close on another repo's issue (only when your task requires it)
```bash ```bash
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/repos/owner/repo/issues/12/comments" -d '{"body":"related to #N"}' "$API/repos/owner/repo/issues/12/comments" -d '{"body":"related to #N"}'
curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/repos/owner/repo/issues/12/labels" -d '{"labels":["related"]}' "$API/repos/owner/repo/issues/12/labels" -d '{"labels":["related"]}'
curl -sS -X PATCH -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ curl -sS -X PATCH -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$API/repos/owner/repo/issues/12" -d '{"state":"closed"}' "$API/repos/owner/repo/issues/12" -d '{"state":"closed"}'
``` ```
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
# Set up read-only SSH alias `node1` (+ opencode skill so the agent actually knows about it).
# 1) Writes the deploy key + an SSH config alias so the agent can run
# `ssh node1 <read-only cmd>` (matches the homelab opencode.json allowlist).
# 2) Emits a `node1-ssh` opencode Skill file under ~/.config/opencode/skills/ so any
# downstream repo's dev agent discovers this capability via OpenCode's skill registry
# rather than having to trial against the permission allowlist. Only emitted when the
# swarm plumbing is actually wired for that caller (SWARM_HOST/SWARM_USER/SSH_PRIV_KEY).
# All three secrets are passed via env and never inlined into shell — this shared workflow
# runs in repos that don't have them and must not fail there.
#
# Required env (provided by the workflow step): SWARM_HOST SWARM_USER SSH_PRIV_KEY
set -eu
if [ -z "$SWARM_HOST" ] || [ -z "$SWARM_USER" ] || [ -z "$SSH_PRIV_KEY" ]; then
echo "swarm secrets not set in this repo — skipping node1 SSH alias + skill"
exit 0
fi
mkdir -p ~/.ssh ~/.config/opencode/skills/node1-ssh && chmod 700 ~/.ssh ~/.config/opencode/skills/node1-ssh
# Write the private key with 600 perms; never echo its contents.
printf '%s\n' "$SSH_PRIV_KEY" > ~/.ssh/agent_node1
chmod 600 ~/.ssh/agent_node1
# SSH config alias `node1` — last-match-wins in the homelab opencode allowlist
# (`deny ssh *` + specific `allow ssh node1 …`), so the alias name is fixed.
cat > ~/.ssh/config <<EOF
Host node1
HostName $SWARM_HOST
User $SWARM_USER
IdentityFile ~/.ssh/agent_node1
IdentitiesOnly yes
StrictHostKeyChecking accept-new
ConnectTimeout 10
EOF
chmod 600 ~/.ssh/config
echo "node1 SSH alias configured (host=$SWARM_HOST user=$SWARM_USER)"
# Emit a reusable opencode Skill that surfaces the capability to downstream agents.
# OpenCode's skill tool registers it via the <available_skills> block, so any dev agent
# can discover "I am allowed to ssh node1" without trial-and-error against the allowlist.
cat > ~/.config/opencode/skills/node1-ssh/SKILL.md <<'SKILLET'
---
name: node1-ssh
description: Read-only diagnostics on the swarm host via `ssh node1 …` — use when debugging a deploy or checking a running service.
domains: [swarm]
tags: [ssh, swarm, diagnostics, docker]
---
# `node1-ssh` Skill
Use this skill to run **read-only** commands against **node1** (the Docker Swarm host) when:
- A deploy failed and you need to inspect running services.
- You need to see a service's logs for debugging.
- You want to check the state of the stack on the swarm.
## How it works
Commands run via `ssh node1 <cmd>`. The SSH alias is configured in `${HOME}/.ssh/config`
during this workflow (only when swarm secrets are configured for the caller repo).
## What you're actually allowed to run — the allowlist is the source of truth
This skill does **not** define which commands are permitted, and you must not assume a fixed
list here. The single source of truth for exactly which `ssh node1 …` commands are allowed is
the **caller repo's own OpenCode permission config** (e.g. `opencode.json` in the homelab repo:
a `deny "ssh *"` with specific `allow "ssh node1 …"` entries, last-match-wins).
- Only read-only diagnostics are permitted; any write/mutating command on node1 is denied.
- The permission layer enforces this — if a command is not on the caller's allowlist it will be
blocked, regardless of what this skill or any other allowlist says.
- So: reach for `ssh node1 …` for read-only diagnostics, and treat the caller's `opencode.json`
`ssh node1` allow-entries as the authoritative list of what will actually run.
## Example
> The frontend returned a 5xx after a deploy.
>
> Action (a read-only log inspection, subject to the caller's allowlist):
> ```
> ssh node1 "docker service logs --tail 100 --timestamps homelab_frontend"
> ```
SKILLET
chmod -R o=rX ~/.config/opencode/skills/node1-ssh
echo "opencode skill node1-ssh installed ($(wc -l < ~/.config/opencode/skills/node1-ssh/SKILL.md) lines)"
+9 -4
View File
@@ -76,11 +76,16 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
|--------|-----| |--------|-----|
| `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) | | `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) |
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) | | `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) |
| `AGENT_TOKEN` | PAT (issue/repository/organization/misc read+write, cross-repo) — posts the delegation comment that fires the next agent **and** powers the `gitea-api` skill (read/write issues, PRs, comments, labels, and Actions runs/logs across any repo). Do not re-narrow its scopes without also removing the `gitea-api` skill. | | `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | **primary** — each agent's own Gitea-user PAT. The running agent gets *only its own* token (as `SELF_TOKEN`) so it posts, commits, comments, and (for `@qa` autopilot) merges as itself, and its `gitea-api` skill acts with its own scopes. `TOKEN_QA` needs `write:repository` to merge. |
| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | optional — post/commit as each agent's own Gitea user (falls back to the bot) | | `TOKEN_OPS` | `@ops` only — the admin PAT behind the `gitea-admin` skill (create orgs/users/repos, manage labels & secrets, mint scoped tokens). Injected into the agent process only when the agent is `@ops`. |
`GITEA_TOKEN` is auto-provided. Tip: set these once at the **org** level so every repo inherits Each agent authenticates as **itself**: the Run-agent step selects that agent's `TOKEN_*` into
them via `secrets: inherit`. `SELF_TOKEN` (never another agent's), and `publish.sh` uses the same token for the delegation/autopilot
trigger comment and `@qa` merges — the two things the built-in `GITEA_TOKEN` can't do (it won't start
new runs, and a merge under it won't fire downstream deploys). So **every consuming repo must carry the
per-agent `TOKEN_*` secrets** (org-level for `gitea/*`, user-level for `ffaerber/*`); there is no shared
fallback token. `GITEA_TOKEN` is auto-provided (used for reads). Tip: set the `TOKEN_*` once at the
**org / user** level so every repo inherits them via `secrets: inherit`.
## Also add to each consuming repo ## Also add to each consuming repo