Phase 1: webhook handler + systemd unit + flock in dev-task

This commit is contained in:
danny8632
2026-05-12 07:06:58 +00:00
parent a296b87065
commit b3ab9639af
3 changed files with 153 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
[Unit]
Description=agent-coding-empire webhook handler
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/agent-webhook.py
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target

128
webhook/handler.py Normal file
View File

@@ -0,0 +1,128 @@
#!/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()