From 0f8893330f27e09c55b8903bb08d21678083d3a8 Mon Sep 17 00:00:00 2001 From: Felix Faerber Date: Sun, 5 Jul 2026 17:35:45 +0300 Subject: [PATCH] =?UTF-8?q?agents:=20caller-provided=20skills=20hook=20?= =?UTF-8?q?=E2=80=94=20repos=20can=20ship=20their=20own=20opencode=20skill?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consuming repo can now add repo-specific skills under .gitea/agent-skills// (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) --- .gitea/workflows/agent.yml | 24 +++--- .gitea/workflows/scripts/agents.json | 60 ++++++++++++-- .../scripts/install-caller-skills.sh | 59 +++++++++++++ .gitea/workflows/scripts/run-agent.sh | 7 +- .gitea/workflows/scripts/skill-node1-ssh.sh | 83 ------------------- 5 files changed, 127 insertions(+), 106 deletions(-) create mode 100644 .gitea/workflows/scripts/install-caller-skills.sh delete mode 100755 .gitea/workflows/scripts/skill-node1-ssh.sh diff --git a/.gitea/workflows/agent.yml b/.gitea/workflows/agent.yml index e373716..9aa81f3 100644 --- a/.gitea/workflows/agent.yml +++ b/.gitea/workflows/agent.yml @@ -112,21 +112,19 @@ jobs: SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load run: bash "$SCRIPTS/install-opencode.sh" - - name: 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 ` (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. + - name: Install caller-provided skills (from the caller repo's .gitea/agent-skills/) + # Framework skill-plugin hook. A consuming repo can ship its OWN opencode skills under + # `.gitea/agent-skills//` (SKILL.md + skill.json + optional setup.sh) — e.g. homelab's + # "ssh into the deploy host" skill. This installs the ones allowed for the running agent, so + # deploy-target / infra specifics live in the repo they belong to, not in this framework. + # SECRETS_JSON = toJSON(secrets): a caller's setup.sh reads the repo-specific secrets it needs + # (whose names this framework can't know) via jq; it never touches disk here in the clear. env: SCRIPTS: ${{ runner.temp }}/agents-scripts - SWARM_HOST: ${{ secrets.SWARM_HOST }} - SWARM_USER: ${{ secrets.SWARM_USER }} - SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }} - run: bash "$SCRIPTS/skill-node1-ssh.sh" + NAME: ${{ steps.prep.outputs.name }} + WORKSPACE: ${{ github.workspace }} + SECRETS_JSON: ${{ toJSON(secrets) }} + run: bash "$SCRIPTS/install-caller-skills.sh" - name: Set up `gitea-api` skill (let agents read/write issues, PRs, Actions across repos) # Emits an opencode Skill file. The skill uses SELF_TOKEN — the running agent's OWN token diff --git a/.gitea/workflows/scripts/agents.json b/.gitea/workflows/scripts/agents.json index c309b2f..ba1e8cb 100644 --- a/.gitea/workflows/scripts/agents.json +++ b/.gitea/workflows/scripts/agents.json @@ -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."}, - "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","node1-ssh"],"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","node1-ssh"],"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)."} -} \ No newline at end of file + "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." + }, + "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)." + } +} diff --git a/.gitea/workflows/scripts/install-caller-skills.sh b/.gitea/workflows/scripts/install-caller-skills.sh new file mode 100644 index 0000000..ea3b4ba --- /dev/null +++ b/.gitea/workflows/scripts/install-caller-skills.sh @@ -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//` 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// +# 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 diff --git a/.gitea/workflows/scripts/run-agent.sh b/.gitea/workflows/scripts/run-agent.sh index 41dc81a..7abfc45 100755 --- a/.gitea/workflows/scripts/run-agent.sh +++ b/.gitea/workflows/scripts/run-agent.sh @@ -12,9 +12,10 @@ set -u [ -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)" 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 -# @senior/@lead hold node1-ssh, so a node1 task must not go to @junior. Skill *names* only; the -# scoped how-to detail stays hidden per the permission.skill allow-list. +# Include each teammate's registry skills so an agent (esp. @pm) can route by capability — e.g. only +# skill-holders should get a task that needs that skill. Skill *names* only; the scoped how-to detail +# 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) 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="" diff --git a/.gitea/workflows/scripts/skill-node1-ssh.sh b/.gitea/workflows/scripts/skill-node1-ssh.sh deleted file mode 100755 index 3db7564..0000000 --- a/.gitea/workflows/scripts/skill-node1-ssh.sh +++ /dev/null @@ -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 ` (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 < 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 `. 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)" -- 2.54.0