diff --git a/docker-compose.yml b/docker-compose.yml index a8f57db..dd5a8d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,27 @@ services: depends_on: - forgejo + runner: + image: disinto/agents:latest + profiles: ["runner"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /usr/local/bin/claude:/usr/local/bin/claude:ro + - ${HOME}/.claude:/home/agent/.claude + - ${HOME}/.claude.json:/home/agent/.claude.json:ro + entrypoint: ["bash", "/home/agent/disinto/docker/runner/entrypoint-runner.sh"] + environment: + - DISINTO_CONTAINER=1 + - FORGE_URL=${FORGE_URL:-} + - FORGE_TOKEN=${FORGE_TOKEN:-} + - FORGE_REPO=${FORGE_REPO:-disinto-admin/disinto} + - FORGE_OPS_REPO=${FORGE_OPS_REPO:-} + - PRIMARY_BRANCH=${PRIMARY_BRANCH:-main} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - CLAUDE_MODEL=${CLAUDE_MODEL:-} + networks: + - default + reproduce: build: context: . diff --git a/docker/edge/dispatcher.sh b/docker/edge/dispatcher.sh index 18beb69..d126b46 100755 --- a/docker/edge/dispatcher.sh +++ b/docker/edge/dispatcher.sh @@ -408,17 +408,10 @@ launch_runner() { local secrets_array secrets_array="${VAULT_ACTION_SECRETS:-}" - # Build command array (safe from shell injection) - local -a cmd=(docker run --rm - --name "vault-runner-${action_id}" - --network disinto_disinto-net - -e "FORGE_URL=${FORGE_URL}" - -e "FORGE_TOKEN=${FORGE_TOKEN}" - -e "FORGE_REPO=${FORGE_REPO}" - -e "FORGE_OPS_REPO=${FORGE_OPS_REPO}" - -e "PRIMARY_BRANCH=${PRIMARY_BRANCH}" - -e DISINTO_CONTAINER=1 - ) + # Build docker compose run command (delegates to compose runner service) + # The runner service definition handles image, network, volumes, and base env. + # The dispatcher only adds declared secrets and the ops repo mount. + local -a cmd=(docker compose run --rm) # Add environment variables for secrets (if any declared) if [ -n "$secrets_array" ]; then @@ -438,27 +431,13 @@ launch_runner() { log "Action ${action_id} has no secrets declared — runner will execute without extra env vars" fi - # Add formula and action id as arguments (safe from shell injection) - local formula="${VAULT_ACTION_FORMULA:-}" - cmd+=(disinto-agents:latest bash -c - "cd /home/agent/disinto && bash formulas/${formula}.sh ${action_id}") + # Mount the ops repo so the runner entrypoint can read the action TOML + cmd+=(-v "${OPS_REPO_ROOT}:/home/agent/ops:ro") - # Log command skeleton (hide all -e flags for security) - local -a log_cmd=() - local skip_next=0 - for arg in "${cmd[@]}"; do - if [[ $skip_next -eq 1 ]]; then - skip_next=0 - continue - fi - if [[ "$arg" == "-e" ]]; then - log_cmd+=("$arg" "") - skip_next=1 - else - log_cmd+=("$arg") - fi - done - log "Running: ${log_cmd[*]}" + # Service name and action-id argument + cmd+=(runner "$action_id") + + log "Running: docker compose run --rm runner ${action_id} (secrets: ${secrets_array:-none})" # Create temp file for logs local log_file diff --git a/docker/runner/entrypoint-runner.sh b/docker/runner/entrypoint-runner.sh new file mode 100644 index 0000000..48e7258 --- /dev/null +++ b/docker/runner/entrypoint-runner.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# entrypoint-runner.sh — Vault runner entrypoint +# +# Receives an action-id, reads the vault action TOML to get the formula name, +# then dispatches to the appropriate executor: +# - formulas/.sh → bash (mechanical operations like release) +# - formulas/.toml → claude -p (reasoning tasks like triage, architect) +# +# Usage: entrypoint-runner.sh +# +# Expects: +# OPS_REPO_ROOT — path to the ops repo (mounted by compose) +# FACTORY_ROOT — path to disinto code (default: /home/agent/disinto) +# +# Part of #516. + +set -euo pipefail + +FACTORY_ROOT="${FACTORY_ROOT:-/home/agent/disinto}" +OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/agent/ops}" + +log() { + printf '[%s] runner: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*" +} + +# ── Argument parsing ───────────────────────────────────────────────────── + +action_id="${1:-}" +if [ -z "$action_id" ]; then + log "ERROR: action-id argument required" + echo "Usage: entrypoint-runner.sh " >&2 + exit 1 +fi + +# ── Read vault action TOML ─────────────────────────────────────────────── + +action_toml="${OPS_REPO_ROOT}/vault/actions/${action_id}.toml" +if [ ! -f "$action_toml" ]; then + log "ERROR: vault action TOML not found: ${action_toml}" + exit 1 +fi + +# Extract formula name from TOML +formula=$(grep -E '^formula\s*=' "$action_toml" \ + | sed -E 's/^formula\s*=\s*"(.*)"/\1/' | tr -d '\r') + +if [ -z "$formula" ]; then + log "ERROR: no 'formula' field found in ${action_toml}" + exit 1 +fi + +# Extract context for logging +context=$(grep -E '^context\s*=' "$action_toml" \ + | sed -E 's/^context\s*=\s*"(.*)"/\1/' | tr -d '\r') + +log "Action: ${action_id}, formula: ${formula}, context: ${context:-}" + +# ── Dispatch: .sh (mechanical) vs .toml (Claude reasoning) ────────────── + +formula_sh="${FACTORY_ROOT}/formulas/${formula}.sh" +formula_toml="${FACTORY_ROOT}/formulas/${formula}.toml" + +if [ -f "$formula_sh" ]; then + # Mechanical operation — run directly + log "Dispatching to shell script: ${formula_sh}" + exec bash "$formula_sh" "$action_id" + +elif [ -f "$formula_toml" ]; then + # Reasoning task — launch Claude with the formula as prompt + log "Dispatching to Claude with formula: ${formula_toml}" + + formula_content=$(cat "$formula_toml") + action_context=$(cat "$action_toml") + + prompt="You are a vault runner executing a formula-based operational task. + +## Vault action +\`\`\`toml +${action_context} +\`\`\` + +## Formula +\`\`\`toml +${formula_content} +\`\`\` + +## Instructions +Execute the steps defined in the formula above. The vault action context provides +the specific parameters for this run. Execute each step in order, verifying +success before proceeding to the next. + +FACTORY_ROOT=${FACTORY_ROOT} +OPS_REPO_ROOT=${OPS_REPO_ROOT} +" + + exec claude -p "$prompt" \ + --dangerously-skip-permissions \ + ${CLAUDE_MODEL:+--model "$CLAUDE_MODEL"} + +else + log "ERROR: no formula found for '${formula}' — checked ${formula_sh} and ${formula_toml}" + exit 1 +fi diff --git a/formulas/release.sh b/formulas/release.sh new file mode 100644 index 0000000..73c3213 --- /dev/null +++ b/formulas/release.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# formulas/release.sh — Mechanical release script +# +# Implements the release workflow without Claude: +# 1. Validate prerequisites +# 2. Tag Forgejo main via API +# 3. Push tag to mirrors (Codeberg, GitHub) via token auth +# 4. Build and tag the agents Docker image +# 5. Restart agent containers +# +# Usage: release.sh +# +# Expects env vars: +# FORGE_URL, FORGE_TOKEN, FORGE_REPO, PRIMARY_BRANCH +# GITHUB_TOKEN — for pushing tags to GitHub mirror +# CODEBERG_TOKEN — for pushing tags to Codeberg mirror +# +# The action TOML context field must contain the version, e.g.: +# context = "Release v1.2.0" +# +# Part of #516. + +set -euo pipefail + +FACTORY_ROOT="${FACTORY_ROOT:-/home/agent/disinto}" +OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/agent/ops}" + +log() { + printf '[%s] release: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*" +} + +# ── Argument parsing ───────────────────────────────────────────────────── + +action_id="${1:-}" +if [ -z "$action_id" ]; then + log "ERROR: action-id argument required" + exit 1 +fi + +action_toml="${OPS_REPO_ROOT}/vault/actions/${action_id}.toml" +if [ ! -f "$action_toml" ]; then + log "ERROR: vault action TOML not found: ${action_toml}" + exit 1 +fi + +# Extract version from context field (e.g. "Release v1.2.0" → "v1.2.0") +context=$(grep -E '^context\s*=' "$action_toml" \ + | sed -E 's/^context\s*=\s*"(.*)"/\1/' | tr -d '\r') +RELEASE_VERSION=$(echo "$context" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+') || true + +if [ -z "${RELEASE_VERSION:-}" ]; then + log "ERROR: could not extract version from context: '${context}'" + log "Context must contain a version like v1.2.0" + exit 1 +fi + +log "Starting release ${RELEASE_VERSION} (action: ${action_id})" + +# ── Step 1: Preflight ──────────────────────────────────────────────────── + +log "Step 1/6: Preflight checks" + +# Validate version format +if ! echo "$RELEASE_VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + log "ERROR: invalid version format: ${RELEASE_VERSION}" + exit 1 +fi + +# Required env vars +for var in FORGE_URL FORGE_TOKEN FORGE_REPO PRIMARY_BRANCH; do + if [ -z "${!var:-}" ]; then + log "ERROR: required env var not set: ${var}" + exit 1 + fi +done + +# Check Docker access +if ! docker info >/dev/null 2>&1; then + log "ERROR: Docker not accessible" + exit 1 +fi + +# Check tag doesn't already exist on Forgejo +if curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${FORGE_URL}/api/v1/repos/${FORGE_REPO}/tags/${RELEASE_VERSION}" >/dev/null 2>&1; then + log "ERROR: tag ${RELEASE_VERSION} already exists on Forgejo" + exit 1 +fi + +log "Preflight passed" + +# ── Step 2: Tag main via Forgejo API ───────────────────────────────────── + +log "Step 2/6: Creating tag ${RELEASE_VERSION} on Forgejo" + +# Get HEAD SHA of primary branch +head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${FORGE_URL}/api/v1/repos/${FORGE_REPO}/branches/${PRIMARY_BRANCH}" \ + | jq -r '.commit.id // empty') + +if [ -z "$head_sha" ]; then + log "ERROR: could not get HEAD SHA for ${PRIMARY_BRANCH}" + exit 1 +fi + +# Create tag via API +curl -sf -X POST \ + -H "Authorization: token ${FORGE_TOKEN}" \ + -H "Content-Type: application/json" \ + "${FORGE_URL}/api/v1/repos/${FORGE_REPO}/tags" \ + -d "{\"tag_name\":\"${RELEASE_VERSION}\",\"target\":\"${head_sha}\",\"message\":\"Release ${RELEASE_VERSION}\"}" \ + >/dev/null + +log "Tag ${RELEASE_VERSION} created (SHA: ${head_sha})" + +# ── Step 3: Push tag to mirrors ────────────────────────────────────────── + +log "Step 3/6: Pushing tag to mirrors" + +# Extract org/repo from FORGE_REPO (e.g. "disinto-admin/disinto" → "disinto") +project_name="${FORGE_REPO##*/}" + +# Push to GitHub mirror (if GITHUB_TOKEN is available) +if [ -n "${GITHUB_TOKEN:-}" ]; then + log "Pushing tag to GitHub mirror" + # Create tag on GitHub via API + if curl -sf -X POST \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/Disinto/${project_name}/git/refs" \ + -d "{\"ref\":\"refs/tags/${RELEASE_VERSION}\",\"sha\":\"${head_sha}\"}" \ + >/dev/null 2>&1; then + log "GitHub: tag pushed" + else + log "WARNING: GitHub tag push failed (may already exist)" + fi +else + log "WARNING: GITHUB_TOKEN not set — skipping GitHub mirror" +fi + +# Push to Codeberg mirror (if CODEBERG_TOKEN is available) +if [ -n "${CODEBERG_TOKEN:-}" ]; then + log "Pushing tag to Codeberg mirror" + # Codeberg uses Gitea-compatible API + # Extract owner from FORGE_REPO for Codeberg (use same owner) + codeberg_owner="${FORGE_REPO%%/*}" + if curl -sf -X POST \ + -H "Authorization: token ${CODEBERG_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://codeberg.org/api/v1/repos/${codeberg_owner}/${project_name}/tags" \ + -d "{\"tag_name\":\"${RELEASE_VERSION}\",\"target\":\"${head_sha}\",\"message\":\"Release ${RELEASE_VERSION}\"}" \ + >/dev/null 2>&1; then + log "Codeberg: tag pushed" + else + log "WARNING: Codeberg tag push failed (may already exist)" + fi +else + log "WARNING: CODEBERG_TOKEN not set — skipping Codeberg mirror" +fi + +# ── Step 4: Build agents Docker image ──────────────────────────────────── + +log "Step 4/6: Building agents Docker image" + +cd "$FACTORY_ROOT/.." || exit 1 +docker compose build --no-cache agents 2>&1 | tail -5 +log "Image built" + +# ── Step 5: Tag image with version ─────────────────────────────────────── + +log "Step 5/6: Tagging image" + +docker tag disinto/agents:latest "disinto/agents:${RELEASE_VERSION}" +log "Tagged disinto/agents:${RELEASE_VERSION}" + +# ── Step 6: Restart agent containers ───────────────────────────────────── + +log "Step 6/6: Restarting agent containers" + +docker compose stop agents agents-llama 2>/dev/null || true +docker compose up -d agents agents-llama +log "Agent containers restarted" + +# ── Done ───────────────────────────────────────────────────────────────── + +log "Release ${RELEASE_VERSION} completed successfully" diff --git a/lib/release.sh b/lib/release.sh index 6eb03ee..1f993ec 100644 --- a/lib/release.sh +++ b/lib/release.sh @@ -96,7 +96,7 @@ disinto_release() { id = "${id}" formula = "release" context = "Release ${version}" -secrets = [] +secrets = ["GITHUB_TOKEN", "CODEBERG_TOKEN"] EOF echo "Created vault item: ${vault_toml}" diff --git a/vault/examples/release.toml b/vault/examples/release.toml index f8af6d1..0f1ce66 100644 --- a/vault/examples/release.toml +++ b/vault/examples/release.toml @@ -12,7 +12,7 @@ # id = "release-v120" # formula = "release" # context = "Release v1.2.0" -# secrets = [] +# secrets = ["GITHUB_TOKEN", "CODEBERG_TOKEN"] # # Steps executed by the release formula: # 1. preflight - Validate prerequisites (version, FORGE_TOKEN, Docker) @@ -26,7 +26,7 @@ id = "release-v120" formula = "release" context = "Release v1.2.0 — includes vault redesign, .profile system, architect agent" -secrets = [] +secrets = ["GITHUB_TOKEN", "CODEBERG_TOKEN"] # Optional: specify a larger model for complex release logic # model = "sonnet" diff --git a/vault/vault-env.sh b/vault/vault-env.sh index 43a4721..d9a17db 100644 --- a/vault/vault-env.sh +++ b/vault/vault-env.sh @@ -29,7 +29,7 @@ fi # ============================================================================= # Allowed secret names - must match keys in .env.vault.enc -VAULT_ALLOWED_SECRETS="CLAWHUB_TOKEN GITHUB_TOKEN DEPLOY_KEY NPM_TOKEN DOCKER_HUB_TOKEN" +VAULT_ALLOWED_SECRETS="CLAWHUB_TOKEN GITHUB_TOKEN CODEBERG_TOKEN DEPLOY_KEY NPM_TOKEN DOCKER_HUB_TOKEN" # Validate a vault action TOML file # Usage: validate_vault_action @@ -99,9 +99,9 @@ validate_vault_action() { return 1 fi - # Validate formula exists in formulas/ - if [ ! -f "$formulas_dir/${formula}.toml" ]; then - echo "ERROR: Formula not found: $formula" >&2 + # Validate formula exists in formulas/ (.toml for Claude reasoning, .sh for mechanical) + if [ ! -f "$formulas_dir/${formula}.toml" ] && [ ! -f "$formulas_dir/${formula}.sh" ]; then + echo "ERROR: Formula not found: $formula (checked .toml and .sh)" >&2 return 1 fi