Adds the Clotho dual-run verification system and hardens the entire agent dispatch pipeline against command injection, token exposure, and SSH MitM attacks. Breaks the agentci→handlers circular dependency. Security: - SanitizePath (regex whitelist + filepath.Base) for all dispatch inputs - EscapeShellArg for shell argument safety - SecureSSHCommand (StrictHostKeyChecking=yes, BatchMode=yes) - ForgeToken removed from ticket JSON, transferred via .env with 0600 - ssh-keyscan on agent add populates known_hosts before first connection Clotho: - Spinner orchestrator determines Standard vs Dual execution mode - Config-driven via ClothoConfig (strategy, validation_threshold) - Agent runner supports claude/codex/gemini backends with dual-run - Divergence detection compares thread outputs via git diff API: - LoadActiveAgents() returns map[string]AgentConfig (no handlers import) - LoadClothoConfig() reads clotho section from config - Forge helpers: AssignIssue, EnsureLabel, AddIssueLabels 32 tests pass (19 agentci + 13 dispatch). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
6.8 KiB
Bash
Executable file
204 lines
6.8 KiB
Bash
Executable file
#!/bin/bash
|
|
# agent-runner.sh — Clotho-Verified Queue Runner for AgentCI.
|
|
# Deployed to agent machines, triggered by cron every 5 minutes.
|
|
#
|
|
# Usage: */5 * * * * ~/ai-work/agent-runner.sh >> ~/ai-work/logs/runner.log 2>&1
|
|
set -euo pipefail
|
|
|
|
WORK_DIR="${HOME}/ai-work"
|
|
QUEUE_DIR="${WORK_DIR}/queue"
|
|
ACTIVE_DIR="${WORK_DIR}/active"
|
|
DONE_DIR="${WORK_DIR}/done"
|
|
LOG_DIR="${WORK_DIR}/logs"
|
|
LOCK_FILE="${WORK_DIR}/.runner.lock"
|
|
|
|
# Ensure directories exist.
|
|
mkdir -p "$QUEUE_DIR" "$ACTIVE_DIR" "$DONE_DIR" "$LOG_DIR"
|
|
|
|
# --- 1. Check lock (is another run active?) ---
|
|
if [ -f "$LOCK_FILE" ]; then
|
|
PID=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
|
|
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
|
|
echo "$(date -Iseconds) Runner already active (PID $PID), exiting."
|
|
exit 0
|
|
fi
|
|
echo "$(date -Iseconds) Removing stale lock (PID $PID)."
|
|
rm -f "$LOCK_FILE"
|
|
fi
|
|
|
|
# --- 2. Pick oldest ticket ---
|
|
TICKET=$(find "$QUEUE_DIR" -name 'ticket-*.json' -type f 2>/dev/null | sort | head -1)
|
|
if [ -z "$TICKET" ]; then
|
|
exit 0 # No work
|
|
fi
|
|
|
|
TICKET_BASENAME=$(basename "$TICKET")
|
|
echo "$(date -Iseconds) Processing ticket: $TICKET_BASENAME"
|
|
|
|
# --- 3. Lock ---
|
|
echo $$ > "$LOCK_FILE"
|
|
cleanup() {
|
|
rm -f "$LOCK_FILE"
|
|
# Secure cleanup of env file if it still exists.
|
|
if [ -n "${ENV_FILE:-}" ] && [ -f "$ENV_FILE" ]; then
|
|
rm -f "$ENV_FILE"
|
|
fi
|
|
echo "$(date -Iseconds) Lock released."
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# --- 4. Move to active ---
|
|
mv "$TICKET" "$ACTIVE_DIR/"
|
|
TICKET_FILE="$ACTIVE_DIR/$TICKET_BASENAME"
|
|
|
|
# --- 5. Extract ticket data ---
|
|
ID=$(jq -r .id "$TICKET_FILE")
|
|
REPO_OWNER=$(jq -r .repo_owner "$TICKET_FILE")
|
|
REPO_NAME=$(jq -r .repo_name "$TICKET_FILE")
|
|
ISSUE_NUM=$(jq -r .issue_number "$TICKET_FILE")
|
|
ISSUE_TITLE=$(jq -r .issue_title "$TICKET_FILE")
|
|
ISSUE_BODY=$(jq -r .issue_body "$TICKET_FILE")
|
|
TARGET_BRANCH=$(jq -r .target_branch "$TICKET_FILE")
|
|
FORGE_URL=$(jq -r .forge_url "$TICKET_FILE")
|
|
DUAL_RUN=$(jq -r '.dual_run // false' "$TICKET_FILE")
|
|
MODEL=$(jq -r '.model // "sonnet"' "$TICKET_FILE")
|
|
RUNNER=$(jq -r '.runner // "claude"' "$TICKET_FILE")
|
|
VERIFY_MODEL=$(jq -r '.verify_model // ""' "$TICKET_FILE")
|
|
|
|
echo "$(date -Iseconds) Issue: ${REPO_OWNER}/${REPO_NAME}#${ISSUE_NUM} - ${ISSUE_TITLE}"
|
|
|
|
# --- 6. Load secure token from .env file ---
|
|
ENV_FILE="$QUEUE_DIR/.env.$ID"
|
|
if [ -f "$ENV_FILE" ]; then
|
|
source "$ENV_FILE"
|
|
rm -f "$ENV_FILE" # Delete immediately after sourcing
|
|
else
|
|
echo "$(date -Iseconds) ERROR: Token file not found for ticket $ID"
|
|
mv "$TICKET_FILE" "$DONE_DIR/"
|
|
exit 1
|
|
fi
|
|
|
|
if [ -z "${FORGE_TOKEN:-}" ]; then
|
|
echo "$(date -Iseconds) ERROR: FORGE_TOKEN missing from env file."
|
|
mv "$TICKET_FILE" "$DONE_DIR/"
|
|
exit 1
|
|
fi
|
|
|
|
# --- 7. Clone or update repo ---
|
|
JOB_DIR="$WORK_DIR/jobs/${REPO_OWNER}-${REPO_NAME}-${ISSUE_NUM}"
|
|
REPO_DIR="$JOB_DIR/$REPO_NAME"
|
|
mkdir -p "$JOB_DIR"
|
|
|
|
FORGEJO_USER=$(jq -r '.forgejo_user // empty' "$TICKET_FILE")
|
|
if [ -z "$FORGEJO_USER" ]; then
|
|
FORGEJO_USER="$(hostname -s)-$(whoami)"
|
|
fi
|
|
# TODO: Replace token-in-URL with git credential helper or SSH clone via charmbracelet/keygen.
|
|
CLONE_URL="https://${FORGEJO_USER}:${FORGE_TOKEN}@${FORGE_URL#https://}/${REPO_OWNER}/${REPO_NAME}.git"
|
|
|
|
if [ -d "$REPO_DIR/.git" ]; then
|
|
echo "$(date -Iseconds) Updating existing clone..."
|
|
cd "$REPO_DIR"
|
|
git fetch origin
|
|
git checkout "$TARGET_BRANCH" 2>/dev/null || git checkout -b "$TARGET_BRANCH" "origin/$TARGET_BRANCH"
|
|
git pull origin "$TARGET_BRANCH"
|
|
else
|
|
echo "$(date -Iseconds) Cloning repo..."
|
|
git clone -b "$TARGET_BRANCH" "$CLONE_URL" "$REPO_DIR"
|
|
cd "$REPO_DIR"
|
|
fi
|
|
|
|
# --- 8. Agent execution function ---
|
|
run_agent() {
|
|
local model="$1"
|
|
local log_suffix="$2"
|
|
local prompt="You are working on issue #${ISSUE_NUM} in ${REPO_OWNER}/${REPO_NAME}.
|
|
|
|
Title: ${ISSUE_TITLE}
|
|
|
|
Description:
|
|
${ISSUE_BODY}
|
|
|
|
The repo is cloned at the current directory on branch '${TARGET_BRANCH}'.
|
|
Create a feature branch from '${TARGET_BRANCH}', make minimal targeted changes, commit referencing #${ISSUE_NUM}, and push.
|
|
Then create a PR targeting '${TARGET_BRANCH}' using the forgejo MCP tools or git push."
|
|
|
|
local log_file="$LOG_DIR/${ID}-${log_suffix}.log"
|
|
echo "$(date -Iseconds) Running ${RUNNER} (model: ${model}, suffix: ${log_suffix})..."
|
|
|
|
case "$RUNNER" in
|
|
codex)
|
|
codex exec --full-auto "$prompt" > "$log_file" 2>&1
|
|
;;
|
|
gemini)
|
|
local model_flag=""
|
|
if [ -n "$model" ] && [ "$model" != "sonnet" ]; then
|
|
model_flag="-m $model"
|
|
fi
|
|
echo "$prompt" | gemini -p - -y $model_flag > "$log_file" 2>&1
|
|
;;
|
|
*)
|
|
echo "$prompt" | claude -p \
|
|
--model "$model" \
|
|
--dangerously-skip-permissions \
|
|
--output-format text \
|
|
> "$log_file" 2>&1
|
|
;;
|
|
esac
|
|
return $?
|
|
}
|
|
|
|
# --- 9. Execute ---
|
|
run_agent "$MODEL" "primary"
|
|
EXIT_CODE_A=$?
|
|
|
|
FINAL_EXIT=$EXIT_CODE_A
|
|
COMMENT=""
|
|
|
|
if [ "$DUAL_RUN" = "true" ] && [ -n "$VERIFY_MODEL" ]; then
|
|
echo "$(date -Iseconds) Clotho Dual Run: resetting for verifier..."
|
|
HASH_A=$(git rev-parse HEAD)
|
|
git checkout "$TARGET_BRANCH" 2>/dev/null || true
|
|
|
|
run_agent "$VERIFY_MODEL" "verifier"
|
|
EXIT_CODE_B=$?
|
|
HASH_B=$(git rev-parse HEAD)
|
|
|
|
# Compare the two runs.
|
|
echo "$(date -Iseconds) Comparing threads..."
|
|
DIFF_COUNT=$(git diff --shortstat "$HASH_A" "$HASH_B" 2>/dev/null | wc -l || echo "1")
|
|
|
|
if [ "$DIFF_COUNT" -eq 0 ] && [ "$EXIT_CODE_A" -eq 0 ] && [ "$EXIT_CODE_B" -eq 0 ]; then
|
|
echo "$(date -Iseconds) Clotho Verification: Threads converged."
|
|
FINAL_EXIT=0
|
|
git checkout "$HASH_A" 2>/dev/null
|
|
git push origin "HEAD:refs/heads/feat/issue-${ISSUE_NUM}"
|
|
else
|
|
echo "$(date -Iseconds) Clotho Verification: Divergence detected."
|
|
FINAL_EXIT=1
|
|
COMMENT="**Clotho Verification Failed**\n\nPrimary ($MODEL) and Verifier ($VERIFY_MODEL) produced divergent results.\nPrimary Exit: $EXIT_CODE_A | Verifier Exit: $EXIT_CODE_B"
|
|
fi
|
|
else
|
|
# Standard single run — push if successful.
|
|
if [ $FINAL_EXIT -eq 0 ]; then
|
|
git push origin "HEAD:refs/heads/feat/issue-${ISSUE_NUM}" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# --- 10. Move to done ---
|
|
mv "$TICKET_FILE" "$DONE_DIR/"
|
|
|
|
# --- 11. Report result back to Forgejo ---
|
|
if [ $FINAL_EXIT -eq 0 ] && [ -z "$COMMENT" ]; then
|
|
COMMENT="Agent completed work on #${ISSUE_NUM}. Exit code: 0."
|
|
elif [ -z "$COMMENT" ]; then
|
|
COMMENT="Agent failed on #${ISSUE_NUM} (exit code: ${FINAL_EXIT}). Check logs on agent machine."
|
|
fi
|
|
|
|
curl -s -X POST "${FORGE_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${ISSUE_NUM}/comments" \
|
|
-H "Authorization: token $FORGE_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$(jq -n --arg body "$COMMENT" '{body: $body}')" \
|
|
> /dev/null 2>&1 || true
|
|
|
|
echo "$(date -Iseconds) Done: $TICKET_BASENAME (exit: $FINAL_EXIT)"
|