#!/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") DEV_LABEL = os.environ.get("AGENT_LABEL", "agent:assign") PM_LABEL = os.environ.get("PM_LABEL", "pm:plan") PM_TASK_BIN = os.environ.get("PM_TASK_BIN", "/usr/local/bin/pm-task.sh") 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 route(event: str, payload: dict) -> tuple[str, str, int] | None: """Decide which task to dispatch. Returns (kind, repo, issue#) or None.""" if event != "issues": return None action = payload.get("action", "") if action not in ("opened", "label_updated"): return None 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", [])] # PM takes priority over dev: a parent issue should be planned before any dev work fires if PM_LABEL in labels: return ("pm", repo, number) if DEV_LABEL in labels: return ("dev", repo, number) return None def dispatch_dev(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-dev-{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-dev ssh rc=%d", rc) def dispatch_pm(repo: str, issue: int): """Run pm-task.sh locally (claude is installed on this host).""" LOG_DIR.mkdir(parents=True, exist_ok=True) fn = LOG_DIR / f"dispatch-pm-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log" log.info("dispatching pm-task for %s#%d -> %s", repo, issue, fn) cmd = [PM_TASK_BIN, repo, str(issue)] with open(fn, "wb") as f: rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode log.info("dispatch-pm 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 r = route(ev, payload) if r is not None: kind, repo, issue = r target = dispatch_pm if kind == "pm" else dispatch_dev threading.Thread(target=target, args=(repo, issue), daemon=True).start() self.send_response(202); self.end_headers() self.wfile.write(f"dispatched {kind}\n".encode()) 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, dev=%s dev_label=%s pm_label=%s", LISTEN_HOST, LISTEN_PORT, DEV_HOST, DEV_LABEL, PM_LABEL) ThreadedServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler).serve_forever()