From d3b04fcd222ac8d65e70ba871947151c23df6a78 Mon Sep 17 00:00:00 2001 From: mawkone Date: Thu, 26 Feb 2026 15:24:21 -0800 Subject: [PATCH] feat: Gitea webhook with HMAC-SHA256 auth, agent label routing, auto-close issues Made-with: Cursor --- dist/agents.js | 6 +++++- dist/server.js | 27 +++++++++++++++++++-------- src/agents.ts | 6 +++++- src/server.ts | 27 +++++++++++++++++++-------- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/dist/agents.js b/dist/agents.js index cdf710e..430088e 100644 --- a/dist/agents.js +++ b/dist/agents.js @@ -70,8 +70,12 @@ Your job is to complete the coding task given to you. Follow these rules: - Never modify .env files or credentials. - Never commit secrets or API keys. +**If you were triggered by a Gitea issue:** +- After committing, close the issue using gitea_close_issue. +- The repo name is in the format "owner/name". + Be methodical. Read before you write. Test before you commit.`, - tools: pick([...FILE_TOOLS, ...SHELL_TOOLS, ...GIT_TOOLS]) + tools: pick([...FILE_TOOLS, ...SHELL_TOOLS, ...GIT_TOOLS, ...GITEA_TOOLS]) }, PM: { name: 'PM', diff --git a/dist/server.js b/dist/server.js index fd74b00..4b755f8 100644 --- a/dist/server.js +++ b/dist/server.js @@ -40,6 +40,7 @@ const express_1 = __importDefault(require("express")); const cors_1 = __importDefault(require("cors")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); +const crypto = __importStar(require("crypto")); const child_process_1 = require("child_process"); const job_store_1 = require("./job-store"); const agent_runner_1 = require("./agent-runner"); @@ -157,19 +158,25 @@ app.get('/api/jobs', (req, res) => { const limit = parseInt(req.query.limit || '20', 10); res.json((0, job_store_1.listJobs)(limit)); }); -// Gitea webhook endpoint — triggers agent from a push/issue event -app.post('/webhook/gitea', (req, res) => { +// Gitea webhook endpoint — triggers agent from an issue event +// Must use raw body for HMAC verification — register before express.json() +app.post('/webhook/gitea', express_1.default.raw({ type: 'application/json' }), (req, res) => { const event = req.headers['x-gitea-event']; - const body = req.body; - // Verify secret if configured + const rawBody = req.body; + // Verify HMAC-SHA256 signature const webhookSecret = process.env.WEBHOOK_SECRET; if (webhookSecret) { const sig = req.headers['x-gitea-signature']; - if (!sig || sig !== webhookSecret) { + const expected = crypto + .createHmac('sha256', webhookSecret) + .update(rawBody) + .digest('hex'); + if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { res.status(401).json({ error: 'Invalid webhook signature' }); return; } } + const body = JSON.parse(rawBody.toString('utf8')); let task = null; let agentName = 'Coder'; let repo; @@ -183,13 +190,17 @@ app.post('/webhook/gitea', (req, res) => { else if (labels.includes('agent:marketing')) { agentName = 'Marketing'; } - else { + else if (labels.includes('agent:coder')) { agentName = 'Coder'; } - task = `Resolve this GitHub issue:\n\nTitle: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}`; + else { + // No agent label — ignore + res.json({ ignored: true, reason: 'no agent label on issue' }); + return; + } + task = `You have been assigned to resolve a Gitea issue in the repo ${repo}.\n\nIssue #${issue.number}: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}\n\nWhen done, close the issue by calling gitea_close_issue.`; } else if (event === 'push') { - // Optionally trigger on push — useful for CI-style automation res.json({ ignored: true, reason: 'push events not auto-processed' }); return; } diff --git a/src/agents.ts b/src/agents.ts index 48bbc91..952706b 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -84,8 +84,12 @@ Your job is to complete the coding task given to you. Follow these rules: - Never modify .env files or credentials. - Never commit secrets or API keys. +**If you were triggered by a Gitea issue:** +- After committing, close the issue using gitea_close_issue. +- The repo name is in the format "owner/name". + Be methodical. Read before you write. Test before you commit.`, - tools: pick([...FILE_TOOLS, ...SHELL_TOOLS, ...GIT_TOOLS]) + tools: pick([...FILE_TOOLS, ...SHELL_TOOLS, ...GIT_TOOLS, ...GITEA_TOOLS]) }, PM: { diff --git a/src/server.ts b/src/server.ts index e61a148..271f7dd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import * as fs from 'fs'; import * as path from 'path'; +import * as crypto from 'crypto'; import { execSync } from 'child_process'; import { createJob, getJob, listJobs, updateJob } from './job-store'; import { runAgent } from './agent-runner'; @@ -136,21 +137,28 @@ app.get('/api/jobs', (req: Request, res: Response) => { res.json(listJobs(limit)); }); -// Gitea webhook endpoint — triggers agent from a push/issue event -app.post('/webhook/gitea', (req: Request, res: Response) => { +// Gitea webhook endpoint — triggers agent from an issue event +// Must use raw body for HMAC verification — register before express.json() +app.post('/webhook/gitea', express.raw({ type: 'application/json' }), (req: Request, res: Response) => { const event = req.headers['x-gitea-event'] as string; - const body = req.body as any; + const rawBody = req.body as Buffer; - // Verify secret if configured + // Verify HMAC-SHA256 signature const webhookSecret = process.env.WEBHOOK_SECRET; if (webhookSecret) { const sig = req.headers['x-gitea-signature'] as string; - if (!sig || sig !== webhookSecret) { + const expected = crypto + .createHmac('sha256', webhookSecret) + .update(rawBody) + .digest('hex'); + if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { res.status(401).json({ error: 'Invalid webhook signature' }); return; } } + const body = JSON.parse(rawBody.toString('utf8')); + let task: string | null = null; let agentName = 'Coder'; let repo: string | undefined; @@ -164,13 +172,16 @@ app.post('/webhook/gitea', (req: Request, res: Response) => { agentName = 'PM'; } else if (labels.includes('agent:marketing')) { agentName = 'Marketing'; - } else { + } else if (labels.includes('agent:coder')) { agentName = 'Coder'; + } else { + // No agent label — ignore + res.json({ ignored: true, reason: 'no agent label on issue' }); + return; } - task = `Resolve this GitHub issue:\n\nTitle: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}`; + task = `You have been assigned to resolve a Gitea issue in the repo ${repo}.\n\nIssue #${issue.number}: ${issue.title}\n\nDescription:\n${issue.body || '(no description)'}\n\nWhen done, close the issue by calling gitea_close_issue.`; } else if (event === 'push') { - // Optionally trigger on push — useful for CI-style automation res.json({ ignored: true, reason: 'push events not auto-processed' }); return; } else {