#!/usr/bin/env python3 """ Gitea webhook handler — agent-coding-empire dispatcher. Listens on TCP :18790 for Gitea webhook deliveries. When an issue acquires the label "agent:assign" (or is opened with that label), SSH-triggers dev-task.sh on dev-01 (192.168.1.29). Verifies the X-Gitea-Signature HMAC header against WEBHOOK_SECRET from /etc/agent/webhook-secret. This is a deliberately tiny single-file service. It uses stdlib only. """ import hashlib import hmac import http.server import json import logging import os import socketserver import subprocess import threading import time from pathlib import Path LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0") LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "18790")) SECRET_FILE = Path(os.environ.get("WEBHOOK_SECRET_FILE", "/etc/agent/webhook-secret")) DEV_HOST = os.environ.get("DEV_HOST", "agent@192.168.1.29") SSH_KEY = os.environ.get("SSH_KEY", "/root/.ssh/id_ed25519_pve") LABEL = os.environ.get("AGENT_LABEL", "agent:assign") LOG_DIR = Path(os.environ.get("HANDLER_LOG_DIR", "/var/log/agent-webhook")) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) log = logging.getLogger("webhook") SECRET = SECRET_FILE.read_text().strip() def verify_signature(body: bytes, sig_header: str) -> bool: if not sig_header: return False mac = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() return hmac.compare_digest(mac, sig_header) def should_dispatch(event: str, payload: dict) -> tuple[bool, str | None, int | None]: """Decide if this delivery should kick off a dev-task. Returns (yes, repo, issue#).""" if event != "issues": return False, None, None action = payload.get("action", "") issue = payload.get("issue", {}) repo = payload.get("repository", {}).get("full_name") number = issue.get("number") labels = [l.get("name", "") for l in issue.get("labels", [])] if action == "opened" and LABEL in labels: return True, repo, number if action == "label_updated" and LABEL in labels: # Gitea fires label_updated for both add and remove; we only care about presence return True, repo, number return False, None, None def dispatch(repo: str, issue: int): """SSH into dev-01 and fire dev-task in the background.""" LOG_DIR.mkdir(parents=True, exist_ok=True) fn = LOG_DIR / f"dispatch-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log" log.info("dispatching dev-task for %s#%d -> %s", repo, issue, fn) cmd = [ "ssh", "-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", DEV_HOST, f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH " f"nohup /usr/local/bin/dev-task.sh '{repo}' {issue} " f">>/var/agent/logs/dispatch-issue-{issue}.log 2>&1 &" ] with open(fn, "wb") as f: rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode log.info("dispatch ssh rc=%d", rc) class WebhookHandler(http.server.BaseHTTPRequestHandler): def do_POST(self): n = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(n) sig = self.headers.get("X-Gitea-Signature", "") ev = self.headers.get("X-Gitea-Event", "") if not verify_signature(body, sig): log.warning("bad signature for event=%s", ev) self.send_response(401); self.end_headers(); self.wfile.write(b"bad sig\n"); return try: payload = json.loads(body) except json.JSONDecodeError: self.send_response(400); self.end_headers(); self.wfile.write(b"bad json\n"); return ok, repo, issue = should_dispatch(ev, payload) if ok and repo and issue is not None: threading.Thread(target=dispatch, args=(repo, issue), daemon=True).start() self.send_response(202); self.end_headers() self.wfile.write(b"dispatched\n") else: self.send_response(200); self.end_headers() self.wfile.write(b"ignored\n") def do_GET(self): if self.path == "/healthz": self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n") else: self.send_response(404); self.end_headers() def log_message(self, fmt, *args): log.info("%s - %s", self.address_string(), fmt % args) class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True if __name__ == "__main__": log.info("agent-webhook listening on %s:%d, target=%s, label=%s", LISTEN_HOST, LISTEN_PORT, DEV_HOST, LABEL) ThreadedServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler).serve_forever()