From e61a8e005342012c5bec6e514487fdd8411a73e3 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Wed, 4 Feb 2026 11:03:47 +0000 Subject: [PATCH 1/3] feat: add cursor-webhooks skill --- skills/cursor-webhooks/SKILL.md | 195 ++++++++++++++++++ .../examples/express/.env.example | 5 + .../examples/express/README.md | 55 +++++ .../examples/express/package.json | 20 ++ .../examples/express/src/index.js | 122 +++++++++++ .../examples/express/test/webhook.test.js | 143 +++++++++++++ .../examples/fastapi/.env.example | 6 + .../examples/fastapi/README.md | 63 ++++++ .../cursor-webhooks/examples/fastapi/main.py | 130 ++++++++++++ .../examples/fastapi/requirements.txt | 5 + .../examples/fastapi/test_webhook.py | 190 +++++++++++++++++ .../examples/nextjs/.env.example | 2 + .../cursor-webhooks/examples/nextjs/README.md | 61 ++++++ .../nextjs/app/webhooks/cursor/route.ts | 118 +++++++++++ .../examples/nextjs/package.json | 23 +++ .../examples/nextjs/test/webhook.test.ts | 178 ++++++++++++++++ .../examples/nextjs/vitest.config.ts | 8 + skills/cursor-webhooks/references/overview.md | 55 +++++ skills/cursor-webhooks/references/setup.md | 53 +++++ .../references/verification.md | 155 ++++++++++++++ 20 files changed, 1587 insertions(+) create mode 100644 skills/cursor-webhooks/SKILL.md create mode 100644 skills/cursor-webhooks/examples/express/.env.example create mode 100644 skills/cursor-webhooks/examples/express/README.md create mode 100644 skills/cursor-webhooks/examples/express/package.json create mode 100644 skills/cursor-webhooks/examples/express/src/index.js create mode 100644 skills/cursor-webhooks/examples/express/test/webhook.test.js create mode 100644 skills/cursor-webhooks/examples/fastapi/.env.example create mode 100644 skills/cursor-webhooks/examples/fastapi/README.md create mode 100644 skills/cursor-webhooks/examples/fastapi/main.py create mode 100644 skills/cursor-webhooks/examples/fastapi/requirements.txt create mode 100644 skills/cursor-webhooks/examples/fastapi/test_webhook.py create mode 100644 skills/cursor-webhooks/examples/nextjs/.env.example create mode 100644 skills/cursor-webhooks/examples/nextjs/README.md create mode 100644 skills/cursor-webhooks/examples/nextjs/app/webhooks/cursor/route.ts create mode 100644 skills/cursor-webhooks/examples/nextjs/package.json create mode 100644 skills/cursor-webhooks/examples/nextjs/test/webhook.test.ts create mode 100644 skills/cursor-webhooks/examples/nextjs/vitest.config.ts create mode 100644 skills/cursor-webhooks/references/overview.md create mode 100644 skills/cursor-webhooks/references/setup.md create mode 100644 skills/cursor-webhooks/references/verification.md diff --git a/skills/cursor-webhooks/SKILL.md b/skills/cursor-webhooks/SKILL.md new file mode 100644 index 0000000..51e2908 --- /dev/null +++ b/skills/cursor-webhooks/SKILL.md @@ -0,0 +1,195 @@ +--- +name: cursor-webhooks +description: > + Receive and verify Cursor Cloud Agent webhooks. Use when setting up Cursor + webhook handlers, debugging signature verification, or handling Cloud Agent + status change events (ERROR, FINISHED). +license: MIT +metadata: + author: hookdeck + version: "0.1.0" + repository: https://github.com/hookdeck/webhook-skills +--- + +# Cursor Webhooks + +## When to Use This Skill + +- Setting up Cursor Cloud Agent webhook handlers +- Debugging signature verification failures +- Understanding Cursor webhook event types and payloads +- Handling Cloud Agent status change events (ERROR, FINISHED) + +## Essential Code (USE THIS) + +### Cursor Signature Verification (JavaScript) + +```javascript +const crypto = require('crypto'); + +function verifyCursorWebhook(rawBody, signatureHeader, secret) { + if (!signatureHeader || !secret) return false; + + // Cursor sends: sha256=xxxx + const [algorithm, signature] = signatureHeader.split('='); + if (algorithm !== 'sha256') return false; + + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + + try { + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); + } catch { + return false; + } +} +``` + +### Express Webhook Handler + +```javascript +const express = require('express'); +const app = express(); + +// CRITICAL: Use express.raw() - Cursor requires raw body for signature verification +app.post('/webhooks/cursor', + express.raw({ type: 'application/json' }), + (req, res) => { + const signature = req.headers['x-webhook-signature']; + const webhookId = req.headers['x-webhook-id']; + const event = req.headers['x-webhook-event']; + + // Verify signature + if (!verifyCursorWebhook(req.body, signature, process.env.CURSOR_WEBHOOK_SECRET)) { + console.error('Cursor signature verification failed'); + return res.status(401).send('Invalid signature'); + } + + // Parse payload after verification + const payload = JSON.parse(req.body.toString()); + + console.log(`Received ${event} (id: ${webhookId})`); + + // Handle status changes + if (event === 'statusChange') { + console.log(`Agent ${payload.id} status: ${payload.status}`); + + if (payload.status === 'FINISHED') { + console.log(`Summary: ${payload.summary}`); + } else if (payload.status === 'ERROR') { + console.error(`Agent error for ${payload.id}`); + } + } + + res.json({ received: true }); + } +); +``` + +### Python Signature Verification (FastAPI) + +```python +import hmac +import hashlib +from fastapi import Request, HTTPException + +def verify_cursor_webhook(body: bytes, signature_header: str, secret: str) -> bool: + if not signature_header or not secret: + return False + + # Cursor sends: sha256=xxxx + parts = signature_header.split('=') + if len(parts) != 2 or parts[0] != 'sha256': + return False + + signature = parts[1] + expected = hmac.new( + secret.encode(), + body, + hashlib.sha256 + ).hexdigest() + + # Timing-safe comparison + return hmac.compare_digest(signature, expected) +``` + +## Common Event Types + +| Event Type | Description | Common Use Cases | +|------------|-------------|------------------| +| `statusChange` | Agent status changed | Monitor agent completion, handle errors | + +### Event Payload Structure + +```json +{ + "event": "statusChange", + "timestamp": "2024-01-01T12:00:00.000Z", + "id": "agent_123456", + "status": "FINISHED", // or "ERROR" + "source": { + "repository": "https://github.com/user/repo", + "ref": "main" + }, + "target": { + "url": "https://github.com/user/repo/pull/123", + "branchName": "feature-branch", + "prUrl": "https://github.com/user/repo/pull/123" + }, + "summary": "Updated 3 files and fixed linting errors" +} +``` + +## Environment Variables + +```bash +# Your Cursor webhook signing secret +CURSOR_WEBHOOK_SECRET=your_webhook_secret_here +``` + +## Local Development + +For local webhook testing, install Hookdeck CLI: + +```bash +# Install via npm +npm install -g hookdeck-cli + +# Or via Homebrew +brew install hookdeck/hookdeck/hookdeck +``` + +Then start the tunnel: + +```bash +hookdeck listen 3000 --path /webhooks/cursor +``` + +No account required. Provides local tunnel + web UI for inspecting requests. + +## Resources + +- `overview.md` - What Cursor webhooks are, event types +- `setup.md` - Configure webhooks in Cursor dashboard +- `verification.md` - Signature verification details and gotchas +- `examples/` - Runnable examples per framework + +## Recommended: webhook-handler-patterns + +For production-ready webhook handling, also use the webhook-handler-patterns skill: + +- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md) +- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md) +- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md) +- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md) + +## Related Skills + +- [stripe-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/stripe-webhooks) - Stripe webhook handling +- [github-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/github-webhooks) - GitHub webhook handling +- [shopify-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/shopify-webhooks) - Shopify webhook handling +- [openai-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/openai-webhooks) - OpenAI webhook handling +- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Idempotency, error handling, retry logic +- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Production webhook infrastructure \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/express/.env.example b/skills/cursor-webhooks/examples/express/.env.example new file mode 100644 index 0000000..2e12f41 --- /dev/null +++ b/skills/cursor-webhooks/examples/express/.env.example @@ -0,0 +1,5 @@ +# Cursor webhook signing secret +CURSOR_WEBHOOK_SECRET=your_webhook_secret_here + +# Server port (optional) +PORT=3000 \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/express/README.md b/skills/cursor-webhooks/examples/express/README.md new file mode 100644 index 0000000..7b1f1da --- /dev/null +++ b/skills/cursor-webhooks/examples/express/README.md @@ -0,0 +1,55 @@ +# Cursor Webhooks - Express Example + +Minimal example of receiving Cursor webhooks with signature verification. + +## Prerequisites + +- Node.js 18+ +- Cursor account with webhook signing secret + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add your Cursor webhook signing secret to `.env` + +## Run + +```bash +npm start +``` + +Server runs on http://localhost:3000 + +## Test + +Run the test suite: + +```bash +npm test +``` + +## Local Development + +Use Hookdeck CLI to receive webhooks locally: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Forward webhooks to your local server +hookdeck listen 3000 --path /webhooks/cursor +``` + +## Endpoints + +- `POST /webhooks/cursor` - Receives and verifies Cursor webhooks +- `GET /health` - Health check endpoint \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/express/package.json b/skills/cursor-webhooks/examples/express/package.json new file mode 100644 index 0000000..285f0a4 --- /dev/null +++ b/skills/cursor-webhooks/examples/express/package.json @@ -0,0 +1,20 @@ +{ + "name": "cursor-webhooks-express", + "version": "1.0.0", + "description": "Cursor webhook handling example for Express", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "test": "jest" + }, + "dependencies": { + "express": "^5.2.1", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "jest": "^30.2.0", + "nodemon": "^3.1.9", + "supertest": "^7.0.0" + } +} \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/express/src/index.js b/skills/cursor-webhooks/examples/express/src/index.js new file mode 100644 index 0000000..e65e200 --- /dev/null +++ b/skills/cursor-webhooks/examples/express/src/index.js @@ -0,0 +1,122 @@ +require('dotenv').config(); +const express = require('express'); +const crypto = require('crypto'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Verify Cursor webhook signature +function verifyCursorWebhook(rawBody, signatureHeader, secret) { + if (!signatureHeader || !secret) { + return false; + } + + // Cursor sends: sha256=xxxx + const parts = signatureHeader.split('='); + if (parts.length !== 2 || parts[0] !== 'sha256') { + return false; + } + + const signature = parts[1]; + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); + } catch { + return false; // Different lengths + } +} + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +// Cursor webhook endpoint +// CRITICAL: Use express.raw() to get the raw body for signature verification +app.post('/webhooks/cursor', + express.raw({ type: 'application/json' }), + (req, res) => { + // Extract headers + const signature = req.headers['x-webhook-signature']; + const webhookId = req.headers['x-webhook-id']; + const event = req.headers['x-webhook-event']; + const userAgent = req.headers['user-agent']; + + console.log(`Received webhook: ${event} (ID: ${webhookId})`); + + // Verify signature + const secret = process.env.CURSOR_WEBHOOK_SECRET; + if (!verifyCursorWebhook(req.body, signature, secret)) { + console.error('Signature verification failed'); + return res.status(401).json({ error: 'Invalid signature' }); + } + + // Parse the payload after verification + let payload; + try { + payload = JSON.parse(req.body.toString()); + } catch (error) { + console.error('Failed to parse payload:', error); + return res.status(400).json({ error: 'Invalid payload' }); + } + + // Handle the event + if (event === 'statusChange') { + console.log(`Agent ${payload.id} status changed to: ${payload.status}`); + console.log(`Timestamp: ${payload.timestamp}`); + + if (payload.source) { + console.log(`Repository: ${payload.source.repository}`); + console.log(`Ref: ${payload.source.ref}`); + } + + if (payload.target) { + console.log(`Target URL: ${payload.target.url}`); + console.log(`Branch: ${payload.target.branchName}`); + if (payload.target.prUrl) { + console.log(`PR URL: ${payload.target.prUrl}`); + } + } + + if (payload.status === 'FINISHED') { + console.log(`Summary: ${payload.summary}`); + // Handle successful completion + // e.g., update database, notify users, trigger CI/CD + } else if (payload.status === 'ERROR') { + console.error(`Agent error for ${payload.id}`); + // Handle error case + // e.g., send alerts, retry logic + } + } + + // Always respond quickly to webhooks + res.json({ received: true }); + } +); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Webhook error:', err); + res.status(500).json({ error: 'Internal server error' }); +}); + +// Start server only if not in test environment +if (process.env.NODE_ENV !== 'test') { + app.listen(PORT, () => { + console.log(`Cursor webhook server running on port ${PORT}`); + + if (!process.env.CURSOR_WEBHOOK_SECRET) { + console.warn('WARNING: CURSOR_WEBHOOK_SECRET not set. Webhooks will fail verification.'); + } + }); +} + +// Export app for testing +module.exports = app; \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/express/test/webhook.test.js b/skills/cursor-webhooks/examples/express/test/webhook.test.js new file mode 100644 index 0000000..db46568 --- /dev/null +++ b/skills/cursor-webhooks/examples/express/test/webhook.test.js @@ -0,0 +1,143 @@ +const request = require('supertest'); +const crypto = require('crypto'); + +// Mock environment variables +process.env.NODE_ENV = 'test'; +process.env.CURSOR_WEBHOOK_SECRET = 'test_secret_key'; + +const app = require('../src/index.js'); + +// Helper to generate valid Cursor webhook signature +function generateSignature(payload, secret) { + const signature = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + return `sha256=${signature}`; +} + +describe('Cursor Webhook Handler', () => { + const validPayload = { + event: 'statusChange', + timestamp: '2024-01-01T12:00:00.000Z', + id: 'agent_123456', + status: 'FINISHED', + source: { + repository: 'https://github.com/test/repo', + ref: 'main' + }, + target: { + url: 'https://github.com/test/repo/pull/123', + branchName: 'feature-branch', + prUrl: 'https://github.com/test/repo/pull/123' + }, + summary: 'Updated 3 files and fixed linting errors' + }; + + test('accepts valid webhook with correct signature', async () => { + const payload = JSON.stringify(validPayload); + const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET); + + const response = await request(app) + .post('/webhooks/cursor') + .set('Content-Type', 'application/json') + .set('X-Webhook-Signature', signature) + .set('X-Webhook-ID', 'msg_123456') + .set('X-Webhook-Event', 'statusChange') + .set('User-Agent', 'Cursor-Agent-Webhook/1.0') + .send(payload); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + test('rejects webhook with invalid signature', async () => { + const payload = JSON.stringify(validPayload); + const invalidSignature = 'sha256=invalid_signature'; + + const response = await request(app) + .post('/webhooks/cursor') + .set('Content-Type', 'application/json') + .set('X-Webhook-Signature', invalidSignature) + .set('X-Webhook-ID', 'msg_123456') + .set('X-Webhook-Event', 'statusChange') + .send(payload); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Invalid signature' }); + }); + + test('rejects webhook with missing signature', async () => { + const payload = JSON.stringify(validPayload); + + const response = await request(app) + .post('/webhooks/cursor') + .set('Content-Type', 'application/json') + .set('X-Webhook-ID', 'msg_123456') + .set('X-Webhook-Event', 'statusChange') + .send(payload); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Invalid signature' }); + }); + + test('rejects webhook with wrong signature format', async () => { + const payload = JSON.stringify(validPayload); + const wrongFormatSignature = 'invalid_format_signature'; + + const response = await request(app) + .post('/webhooks/cursor') + .set('Content-Type', 'application/json') + .set('X-Webhook-Signature', wrongFormatSignature) + .set('X-Webhook-ID', 'msg_123456') + .set('X-Webhook-Event', 'statusChange') + .send(payload); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Invalid signature' }); + }); + + test('handles ERROR status', async () => { + const errorPayload = { + ...validPayload, + status: 'ERROR' + }; + const payload = JSON.stringify(errorPayload); + const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET); + + const response = await request(app) + .post('/webhooks/cursor') + .set('Content-Type', 'application/json') + .set('X-Webhook-Signature', signature) + .set('X-Webhook-ID', 'msg_123456') + .set('X-Webhook-Event', 'statusChange') + .send(payload); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: true }); + }); + + test('rejects invalid JSON payload', async () => { + const invalidJson = '{"invalid": json}'; + const signature = generateSignature(invalidJson, process.env.CURSOR_WEBHOOK_SECRET); + + const response = await request(app) + .post('/webhooks/cursor') + .set('Content-Type', 'application/json') + .set('X-Webhook-Signature', signature) + .set('X-Webhook-ID', 'msg_123456') + .set('X-Webhook-Event', 'statusChange') + .send(invalidJson); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Invalid payload' }); + }); + + test('health check returns ok', async () => { + const response = await request(app) + .get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'ok' }); + }); +}); \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/fastapi/.env.example b/skills/cursor-webhooks/examples/fastapi/.env.example new file mode 100644 index 0000000..635c866 --- /dev/null +++ b/skills/cursor-webhooks/examples/fastapi/.env.example @@ -0,0 +1,6 @@ +# Cursor webhook signing secret +CURSOR_WEBHOOK_SECRET=your_webhook_secret_here + +# Server configuration (optional) +HOST=0.0.0.0 +PORT=8000 \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/fastapi/README.md b/skills/cursor-webhooks/examples/fastapi/README.md new file mode 100644 index 0000000..58ec763 --- /dev/null +++ b/skills/cursor-webhooks/examples/fastapi/README.md @@ -0,0 +1,63 @@ +# Cursor Webhooks - FastAPI Example + +Minimal example of receiving Cursor webhooks with signature verification in FastAPI. + +## Prerequisites + +- Python 3.9+ +- Cursor account with webhook signing secret + +## Setup + +1. Create a virtual environment: + ```bash + python3 -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Copy environment variables: + ```bash + cp .env.example .env + ``` + +4. Add your Cursor webhook signing secret to `.env` + +## Run + +```bash +uvicorn main:app --reload +``` + +Server runs on http://localhost:8000 + +API documentation available at http://localhost:8000/docs + +## Test + +Run the test suite: + +```bash +pytest test_webhook.py +``` + +## Local Development + +Use Hookdeck CLI to receive webhooks locally: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Forward webhooks to your local server +hookdeck listen 8000 --path /webhooks/cursor +``` + +## Endpoints + +- `POST /webhooks/cursor` - Receives and verifies Cursor webhooks +- `GET /health` - Health check endpoint \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/fastapi/main.py b/skills/cursor-webhooks/examples/fastapi/main.py new file mode 100644 index 0000000..8d4127d --- /dev/null +++ b/skills/cursor-webhooks/examples/fastapi/main.py @@ -0,0 +1,130 @@ +import os +import hmac +import hashlib +import json +from typing import Optional +from fastapi import FastAPI, Request, HTTPException, Header +from fastapi.responses import JSONResponse +from dotenv import load_dotenv +import logging + +# Load environment variables +load_dotenv() + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI() + + +def verify_cursor_webhook(body: bytes, signature_header: str, secret: str) -> bool: + """Verify Cursor webhook signature.""" + if not signature_header or not secret: + return False + + # Cursor sends: sha256=xxxx + parts = signature_header.split('=') + if len(parts) != 2 or parts[0] != 'sha256': + return False + + signature = parts[1] + expected = hmac.new( + secret.encode(), + body, + hashlib.sha256 + ).hexdigest() + + # Timing-safe comparison + return hmac.compare_digest(signature, expected) + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok"} + + +@app.post("/webhooks/cursor") +async def handle_cursor_webhook( + request: Request, + x_webhook_signature: Optional[str] = Header(None), + x_webhook_id: Optional[str] = Header(None), + x_webhook_event: Optional[str] = Header(None), + user_agent: Optional[str] = Header(None) +): + """Handle Cursor webhook.""" + logger.info(f"Received webhook: {x_webhook_event} (ID: {x_webhook_id})") + + # Get raw body for signature verification + body = await request.body() + + # Verify signature + secret = os.getenv('CURSOR_WEBHOOK_SECRET') + if not secret: + logger.error("CURSOR_WEBHOOK_SECRET not configured") + raise HTTPException(status_code=500, detail="Server configuration error") + + if not verify_cursor_webhook(body, x_webhook_signature, secret): + logger.error("Signature verification failed") + raise HTTPException(status_code=401, detail="Invalid signature") + + # Parse the payload after verification + try: + payload = json.loads(body.decode()) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse payload: {e}") + raise HTTPException(status_code=400, detail="Invalid payload") + + # Handle the event + if x_webhook_event == 'statusChange': + agent_id = payload.get('id') + status = payload.get('status') + timestamp = payload.get('timestamp') + + logger.info(f"Agent {agent_id} status changed to: {status}") + logger.info(f"Timestamp: {timestamp}") + + if 'source' in payload: + logger.info(f"Repository: {payload['source'].get('repository')}") + logger.info(f"Ref: {payload['source'].get('ref')}") + + if 'target' in payload: + logger.info(f"Target URL: {payload['target'].get('url')}") + logger.info(f"Branch: {payload['target'].get('branchName')}") + if 'prUrl' in payload['target']: + logger.info(f"PR URL: {payload['target']['prUrl']}") + + if status == 'FINISHED': + logger.info(f"Summary: {payload.get('summary')}") + # Handle successful completion + # e.g., update database, notify users, trigger CI/CD + elif status == 'ERROR': + logger.error(f"Agent error for {agent_id}") + # Handle error case + # e.g., send alerts, retry logic + + # Always respond quickly to webhooks + return JSONResponse(content={"received": True}) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Handle unexpected errors.""" + logger.error(f"Webhook error: {exc}") + return JSONResponse( + status_code=500, + content={"error": "Internal server error"} + ) + + +if __name__ == "__main__": + import uvicorn + + host = os.getenv("HOST", "0.0.0.0") + port = int(os.getenv("PORT", "8000")) + + if not os.getenv("CURSOR_WEBHOOK_SECRET"): + logger.warning("WARNING: CURSOR_WEBHOOK_SECRET not set. Webhooks will fail verification.") + + uvicorn.run(app, host=host, port=port) \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/fastapi/requirements.txt b/skills/cursor-webhooks/examples/fastapi/requirements.txt new file mode 100644 index 0000000..098d595 --- /dev/null +++ b/skills/cursor-webhooks/examples/fastapi/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.128.0 +uvicorn[standard]>=0.34.0 +python-dotenv>=1.0.0 +pytest>=9.0.2 +httpx>=0.28.1 \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/fastapi/test_webhook.py b/skills/cursor-webhooks/examples/fastapi/test_webhook.py new file mode 100644 index 0000000..6a2d613 --- /dev/null +++ b/skills/cursor-webhooks/examples/fastapi/test_webhook.py @@ -0,0 +1,190 @@ +import os +import json +import hmac +import hashlib +import pytest +from fastapi.testclient import TestClient + +# Set test environment variable +os.environ['CURSOR_WEBHOOK_SECRET'] = 'test_secret_key' + +from main import app + +client = TestClient(app) + + +def generate_signature(payload: bytes, secret: str) -> str: + """Generate valid Cursor webhook signature.""" + signature = hmac.new( + secret.encode(), + payload, + hashlib.sha256 + ).hexdigest() + return f"sha256={signature}" + + +class TestCursorWebhook: + """Test Cursor webhook handler.""" + + def setup_method(self): + """Set up test data.""" + self.valid_payload = { + "event": "statusChange", + "timestamp": "2024-01-01T12:00:00.000Z", + "id": "agent_123456", + "status": "FINISHED", + "source": { + "repository": "https://github.com/test/repo", + "ref": "main" + }, + "target": { + "url": "https://github.com/test/repo/pull/123", + "branchName": "feature-branch", + "prUrl": "https://github.com/test/repo/pull/123" + }, + "summary": "Updated 3 files and fixed linting errors" + } + + def test_valid_webhook(self): + """Test accepting valid webhook with correct signature.""" + payload = json.dumps(self.valid_payload).encode() + signature = generate_signature(payload, os.environ['CURSOR_WEBHOOK_SECRET']) + + response = client.post( + "/webhooks/cursor", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Webhook-ID": "msg_123456", + "X-Webhook-Event": "statusChange", + "User-Agent": "Cursor-Agent-Webhook/1.0" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_invalid_signature(self): + """Test rejecting webhook with invalid signature.""" + payload = json.dumps(self.valid_payload).encode() + + response = client.post( + "/webhooks/cursor", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": "sha256=invalid_signature", + "X-Webhook-ID": "msg_123456", + "X-Webhook-Event": "statusChange" + } + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid signature"} + + def test_missing_signature(self): + """Test rejecting webhook with missing signature.""" + payload = json.dumps(self.valid_payload).encode() + + response = client.post( + "/webhooks/cursor", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Webhook-ID": "msg_123456", + "X-Webhook-Event": "statusChange" + } + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid signature"} + + def test_wrong_signature_format(self): + """Test rejecting webhook with wrong signature format.""" + payload = json.dumps(self.valid_payload).encode() + + response = client.post( + "/webhooks/cursor", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": "invalid_format_signature", + "X-Webhook-ID": "msg_123456", + "X-Webhook-Event": "statusChange" + } + ) + + assert response.status_code == 401 + assert response.json() == {"detail": "Invalid signature"} + + def test_error_status(self): + """Test handling ERROR status.""" + error_payload = self.valid_payload.copy() + error_payload["status"] = "ERROR" + payload = json.dumps(error_payload).encode() + signature = generate_signature(payload, os.environ['CURSOR_WEBHOOK_SECRET']) + + response = client.post( + "/webhooks/cursor", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Webhook-ID": "msg_123456", + "X-Webhook-Event": "statusChange" + } + ) + + assert response.status_code == 200 + assert response.json() == {"received": True} + + def test_invalid_json(self): + """Test rejecting invalid JSON payload.""" + invalid_json = b'{"invalid": json}' + signature = generate_signature(invalid_json, os.environ['CURSOR_WEBHOOK_SECRET']) + + response = client.post( + "/webhooks/cursor", + content=invalid_json, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Webhook-ID": "msg_123456", + "X-Webhook-Event": "statusChange" + } + ) + + assert response.status_code == 400 + assert response.json() == {"detail": "Invalid payload"} + + def test_health_check(self): + """Test health check endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + def test_missing_env_variable(self): + """Test handling missing environment variable.""" + # Temporarily remove the secret + original_secret = os.environ.get('CURSOR_WEBHOOK_SECRET') + del os.environ['CURSOR_WEBHOOK_SECRET'] + + payload = json.dumps(self.valid_payload).encode() + response = client.post( + "/webhooks/cursor", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": "sha256=any", + "X-Webhook-ID": "msg_123456", + "X-Webhook-Event": "statusChange" + } + ) + + assert response.status_code == 500 + assert response.json() == {"detail": "Server configuration error"} + + # Restore the secret + if original_secret: + os.environ['CURSOR_WEBHOOK_SECRET'] = original_secret \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/nextjs/.env.example b/skills/cursor-webhooks/examples/nextjs/.env.example new file mode 100644 index 0000000..ee484ee --- /dev/null +++ b/skills/cursor-webhooks/examples/nextjs/.env.example @@ -0,0 +1,2 @@ +# Cursor webhook signing secret +CURSOR_WEBHOOK_SECRET=your_webhook_secret_here \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/nextjs/README.md b/skills/cursor-webhooks/examples/nextjs/README.md new file mode 100644 index 0000000..3c6c252 --- /dev/null +++ b/skills/cursor-webhooks/examples/nextjs/README.md @@ -0,0 +1,61 @@ +# Cursor Webhooks - Next.js Example + +Minimal example of receiving Cursor webhooks with signature verification in Next.js App Router. + +## Prerequisites + +- Node.js 18+ +- Cursor account with webhook signing secret + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env.local + ``` + +3. Add your Cursor webhook signing secret to `.env.local` + +## Run + +Development mode: +```bash +npm run dev +``` + +Production mode: +```bash +npm run build +npm start +``` + +Server runs on http://localhost:3000 + +## Test + +Run the test suite: + +```bash +npm test +``` + +## Local Development + +Use Hookdeck CLI to receive webhooks locally: + +```bash +# Install Hookdeck CLI +npm install -g hookdeck-cli + +# Forward webhooks to your local server +hookdeck listen 3000 --path /webhooks/cursor +``` + +## API Routes + +- `POST /webhooks/cursor` - Receives and verifies Cursor webhooks \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/nextjs/app/webhooks/cursor/route.ts b/skills/cursor-webhooks/examples/nextjs/app/webhooks/cursor/route.ts new file mode 100644 index 0000000..b89b218 --- /dev/null +++ b/skills/cursor-webhooks/examples/nextjs/app/webhooks/cursor/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +// Verify Cursor webhook signature +function verifyCursorWebhook( + rawBody: Buffer, + signatureHeader: string | null, + secret: string +): boolean { + if (!signatureHeader || !secret) { + return false; + } + + // Cursor sends: sha256=xxxx + const parts = signatureHeader.split('='); + if (parts.length !== 2 || parts[0] !== 'sha256') { + return false; + } + + const signature = parts[1]; + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); + } catch { + return false; // Different lengths + } +} + +export async function POST(request: NextRequest) { + try { + // Get raw body + const rawBody = await request.arrayBuffer(); + const body = Buffer.from(rawBody); + + // Extract headers + const signature = request.headers.get('x-webhook-signature'); + const webhookId = request.headers.get('x-webhook-id'); + const event = request.headers.get('x-webhook-event'); + const userAgent = request.headers.get('user-agent'); + + console.log(`Received webhook: ${event} (ID: ${webhookId})`); + + // Verify signature + const secret = process.env.CURSOR_WEBHOOK_SECRET; + if (!secret) { + console.error('CURSOR_WEBHOOK_SECRET not configured'); + return NextResponse.json( + { error: 'Server configuration error' }, + { status: 500 } + ); + } + + if (!verifyCursorWebhook(body, signature, secret)) { + console.error('Signature verification failed'); + return NextResponse.json( + { error: 'Invalid signature' }, + { status: 401 } + ); + } + + // Parse the payload after verification + let payload: any; + try { + payload = JSON.parse(body.toString()); + } catch (error) { + console.error('Failed to parse payload:', error); + return NextResponse.json( + { error: 'Invalid payload' }, + { status: 400 } + ); + } + + // Handle the event + if (event === 'statusChange') { + console.log(`Agent ${payload.id} status changed to: ${payload.status}`); + console.log(`Timestamp: ${payload.timestamp}`); + + if (payload.source) { + console.log(`Repository: ${payload.source.repository}`); + console.log(`Ref: ${payload.source.ref}`); + } + + if (payload.target) { + console.log(`Target URL: ${payload.target.url}`); + console.log(`Branch: ${payload.target.branchName}`); + if (payload.target.prUrl) { + console.log(`PR URL: ${payload.target.prUrl}`); + } + } + + if (payload.status === 'FINISHED') { + console.log(`Summary: ${payload.summary}`); + // Handle successful completion + // e.g., update database, notify users, trigger CI/CD + } else if (payload.status === 'ERROR') { + console.error(`Agent error for ${payload.id}`); + // Handle error case + // e.g., send alerts, retry logic + } + } + + // Always respond quickly to webhooks + return NextResponse.json({ received: true }); + } catch (error) { + console.error('Webhook processing error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/nextjs/package.json b/skills/cursor-webhooks/examples/nextjs/package.json new file mode 100644 index 0000000..0587817 --- /dev/null +++ b/skills/cursor-webhooks/examples/nextjs/package.json @@ -0,0 +1,23 @@ +{ + "name": "cursor-webhooks-nextjs", + "version": "1.0.0", + "description": "Cursor webhook handling example for Next.js", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "dependencies": { + "next": "^16.1.6", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^20.11.19", + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/nextjs/test/webhook.test.ts b/skills/cursor-webhooks/examples/nextjs/test/webhook.test.ts new file mode 100644 index 0000000..a70d275 --- /dev/null +++ b/skills/cursor-webhooks/examples/nextjs/test/webhook.test.ts @@ -0,0 +1,178 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import crypto from 'crypto'; +import { POST } from '../app/webhooks/cursor/route'; +import { NextRequest } from 'next/server'; + +// Mock environment variables +process.env.CURSOR_WEBHOOK_SECRET = 'test_secret_key'; + +// Helper to generate valid Cursor webhook signature +function generateSignature(payload: string, secret: string): string { + const signature = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + return `sha256=${signature}`; +} + +// Helper to create NextRequest with proper headers +function createRequest( + payload: string, + headers: Record +): NextRequest { + const url = 'http://localhost:3000/webhooks/cursor'; + return new NextRequest(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: payload + }); +} + +describe('Cursor Webhook Handler', () => { + const validPayload = { + event: 'statusChange', + timestamp: '2024-01-01T12:00:00.000Z', + id: 'agent_123456', + status: 'FINISHED', + source: { + repository: 'https://github.com/test/repo', + ref: 'main' + }, + target: { + url: 'https://github.com/test/repo/pull/123', + branchName: 'feature-branch', + prUrl: 'https://github.com/test/repo/pull/123' + }, + summary: 'Updated 3 files and fixed linting errors' + }; + + test('accepts valid webhook with correct signature', async () => { + const payload = JSON.stringify(validPayload); + const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET!); + + const request = createRequest(payload, { + 'X-Webhook-Signature': signature, + 'X-Webhook-ID': 'msg_123456', + 'X-Webhook-Event': 'statusChange', + 'User-Agent': 'Cursor-Agent-Webhook/1.0' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + test('rejects webhook with invalid signature', async () => { + const payload = JSON.stringify(validPayload); + const invalidSignature = 'sha256=invalid_signature'; + + const request = createRequest(payload, { + 'X-Webhook-Signature': invalidSignature, + 'X-Webhook-ID': 'msg_123456', + 'X-Webhook-Event': 'statusChange' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data).toEqual({ error: 'Invalid signature' }); + }); + + test('rejects webhook with missing signature', async () => { + const payload = JSON.stringify(validPayload); + + const request = createRequest(payload, { + 'X-Webhook-ID': 'msg_123456', + 'X-Webhook-Event': 'statusChange' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data).toEqual({ error: 'Invalid signature' }); + }); + + test('rejects webhook with wrong signature format', async () => { + const payload = JSON.stringify(validPayload); + const wrongFormatSignature = 'invalid_format_signature'; + + const request = createRequest(payload, { + 'X-Webhook-Signature': wrongFormatSignature, + 'X-Webhook-ID': 'msg_123456', + 'X-Webhook-Event': 'statusChange' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data).toEqual({ error: 'Invalid signature' }); + }); + + test('handles ERROR status', async () => { + const errorPayload = { + ...validPayload, + status: 'ERROR' + }; + const payload = JSON.stringify(errorPayload); + const signature = generateSignature(payload, process.env.CURSOR_WEBHOOK_SECRET!); + + const request = createRequest(payload, { + 'X-Webhook-Signature': signature, + 'X-Webhook-ID': 'msg_123456', + 'X-Webhook-Event': 'statusChange' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ received: true }); + }); + + test('rejects invalid JSON payload', async () => { + const invalidJson = '{"invalid": json}'; + const signature = generateSignature(invalidJson, process.env.CURSOR_WEBHOOK_SECRET!); + + const request = createRequest(invalidJson, { + 'X-Webhook-Signature': signature, + 'X-Webhook-ID': 'msg_123456', + 'X-Webhook-Event': 'statusChange' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ error: 'Invalid payload' }); + }); + + test('handles missing environment variable', async () => { + // Temporarily remove the secret + const originalSecret = process.env.CURSOR_WEBHOOK_SECRET; + delete process.env.CURSOR_WEBHOOK_SECRET; + + const payload = JSON.stringify(validPayload); + const request = createRequest(payload, { + 'X-Webhook-Signature': 'sha256=any', + 'X-Webhook-ID': 'msg_123456', + 'X-Webhook-Event': 'statusChange' + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: 'Server configuration error' }); + + // Restore the secret + process.env.CURSOR_WEBHOOK_SECRET = originalSecret; + }); +}); \ No newline at end of file diff --git a/skills/cursor-webhooks/examples/nextjs/vitest.config.ts b/skills/cursor-webhooks/examples/nextjs/vitest.config.ts new file mode 100644 index 0000000..7192c61 --- /dev/null +++ b/skills/cursor-webhooks/examples/nextjs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node' + } +}); \ No newline at end of file diff --git a/skills/cursor-webhooks/references/overview.md b/skills/cursor-webhooks/references/overview.md new file mode 100644 index 0000000..c88bf58 --- /dev/null +++ b/skills/cursor-webhooks/references/overview.md @@ -0,0 +1,55 @@ +# Cursor Webhooks Overview + +## What Are Cursor Webhooks? + +Cursor Cloud Agent webhooks are HTTP callbacks that notify your application when agent status changes occur. These webhooks enable real-time monitoring of Cloud Agent operations, allowing you to track when agents complete tasks or encounter errors. + +## Common Event Types + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `statusChange` | Agent status changes to ERROR or FINISHED | Monitor agent completion, handle errors, update UI | + +## Event Payload Structure + +All Cursor webhooks share a consistent payload structure: + +```json +{ + "event": "statusChange", + "timestamp": "2024-01-01T12:00:00.000Z", + "id": "agent_123456", + "status": "FINISHED", + "source": { + "repository": "https://github.com/user/repo", + "ref": "main" + }, + "target": { + "url": "https://github.com/user/repo/pull/123", + "branchName": "feature-branch", + "prUrl": "https://github.com/user/repo/pull/123" + }, + "summary": "Updated 3 files and fixed linting errors" +} +``` + +### Status Values + +- `FINISHED` - Agent completed successfully +- `ERROR` - Agent encountered an error + +## HTTP Headers + +Cursor sends these headers with every webhook: + +| Header | Description | Example | +|--------|-------------|---------| +| `X-Webhook-Signature` | HMAC-SHA256 signature | `sha256=abc123...` | +| `X-Webhook-ID` | Unique delivery ID | `msg_01234567890` | +| `X-Webhook-Event` | Event type | `statusChange` | +| `User-Agent` | Identifies Cursor webhooks | `Cursor-Agent-Webhook/1.0` | +| `Content-Type` | Payload format | `application/json` | + +## Full Event Reference + +For the complete list of events and detailed specifications, see [Cursor's webhook documentation](https://cursor.com/docs/cloud-agent/api/webhooks). \ No newline at end of file diff --git a/skills/cursor-webhooks/references/setup.md b/skills/cursor-webhooks/references/setup.md new file mode 100644 index 0000000..5a642cd --- /dev/null +++ b/skills/cursor-webhooks/references/setup.md @@ -0,0 +1,53 @@ +# Setting Up Cursor Webhooks + +## Prerequisites + +- Cursor Cloud Agent access +- Your application's webhook endpoint URL +- Admin access to configure webhooks in your Cursor settings + +## Get Your Signing Secret + +1. Log in to your Cursor dashboard +2. Navigate to Cloud Agent settings +3. Go to the Webhooks section +4. Copy your webhook signing secret + - Keep this secret secure + - Never commit it to version control + - Rotate it periodically for security + +## Register Your Endpoint + +1. In the Cursor Cloud Agent settings, click "Add Webhook" +2. Enter your webhook endpoint URL: + - Production: `https://yourdomain.com/webhooks/cursor` + - Development: Use Hookdeck CLI tunnel URL +3. Select the events to receive: + - `statusChange` - Notifies when agent status changes +4. Save the webhook configuration + +## Test Your Webhook + +1. Cursor will send a test `statusChange` event to verify your endpoint +2. Your endpoint should: + - Return a 200 status code + - Verify the signature + - Process the test payload + +## Environment Configuration + +Add your signing secret to your environment: + +```bash +# .env file +CURSOR_WEBHOOK_SECRET=your_webhook_secret_here +``` + +## Security Best Practices + +- Always verify webhook signatures +- Use HTTPS endpoints only +- Store secrets in environment variables +- Implement request timeouts +- Log webhook events for debugging +- Return 200 quickly, process asynchronously if needed \ No newline at end of file diff --git a/skills/cursor-webhooks/references/verification.md b/skills/cursor-webhooks/references/verification.md new file mode 100644 index 0000000..665a98b --- /dev/null +++ b/skills/cursor-webhooks/references/verification.md @@ -0,0 +1,155 @@ +# Cursor Signature Verification + +## How It Works + +Cursor uses HMAC-SHA256 to sign webhook payloads. The signature is sent in the `X-Webhook-Signature` header with the format `sha256=`. + +The signature is computed by: +1. Taking the raw request body (before parsing) +2. Creating an HMAC-SHA256 hash using your webhook secret +3. Encoding the result as hexadecimal +4. Prefixing with `sha256=` + +## Implementation + +### Manual Verification (Recommended) + +```javascript +const crypto = require('crypto'); + +function verifyCursorWebhook(rawBody, signatureHeader, secret) { + if (!signatureHeader || !secret) { + return false; + } + + // Extract algorithm and signature + const [algorithm, signature] = signatureHeader.split('='); + if (algorithm !== 'sha256') { + return false; + } + + // Calculate expected signature + const expected = crypto + .createHmac('sha256', secret) + .update(rawBody) + .digest('hex'); + + // Timing-safe comparison + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expected) + ); + } catch { + return false; // Different lengths + } +} +``` + +### Python Implementation + +```python +import hmac +import hashlib + +def verify_cursor_webhook(body: bytes, signature_header: str, secret: str) -> bool: + if not signature_header or not secret: + return False + + # Extract algorithm and signature + parts = signature_header.split('=') + if len(parts) != 2 or parts[0] != 'sha256': + return False + + signature = parts[1] + + # Calculate expected signature + expected = hmac.new( + secret.encode(), + body, + hashlib.sha256 + ).hexdigest() + + # Timing-safe comparison + return hmac.compare_digest(signature, expected) +``` + +## Common Gotchas + +### 1. Raw Body Parsing + +**Problem**: Using parsed JSON instead of raw body breaks signature verification. + +**Solution**: Always use the raw request body: +```javascript +// Express +app.use('/webhooks/cursor', express.raw({ type: 'application/json' })); + +// Next.js +export const config = { api: { bodyParser: false } }; + +// FastAPI +body = await request.body() # Get raw bytes +``` + +### 2. Header Case Sensitivity + +**Problem**: Some frameworks lowercase headers. + +**Solution**: Access headers case-insensitively: +```javascript +// Express normalizes to lowercase +const signature = req.headers['x-webhook-signature']; + +// FastAPI preserves case +signature = request.headers.get('X-Webhook-Signature') +``` + +### 3. Missing Timing-Safe Comparison + +**Problem**: Using `===` for comparison is vulnerable to timing attacks. + +**Solution**: Always use timing-safe comparison: +```javascript +// Good +crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) + +// Bad +signature === expected +``` + +### 4. Incorrect Secret Format + +**Problem**: Using the wrong secret or format. + +**Solution**: Use the exact secret from Cursor dashboard, no modifications. + +## Debugging Verification Failures + +If signature verification fails: + +1. **Check the raw body**: Log the exact bytes being signed +2. **Verify the secret**: Ensure no extra whitespace or encoding issues +3. **Check headers**: Log the exact signature header value +4. **Compare signatures**: Log both calculated and received signatures (in development only) + +### Debug Helper + +```javascript +function debugWebhook(rawBody, signatureHeader, secret) { + console.log('=== Webhook Debug ==='); + console.log('Body length:', rawBody.length); + console.log('Body preview:', rawBody.toString().substring(0, 100)); + console.log('Signature header:', signatureHeader); + + const [algorithm, signature] = signatureHeader.split('='); + const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + + console.log('Received sig:', signature); + console.log('Expected sig:', expected); + console.log('Match:', signature === expected); + console.log('==================='); +} +``` + +**Important**: Only use debug logging in development. Never log signatures or secrets in production. \ No newline at end of file From a725faec267cbf4367463740d69ae773a875f0d2 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Feb 2026 18:33:19 +0000 Subject: [PATCH 2/3] fix: update validate-provider.sh for dynamic test scenarios test-agent-scenario.sh now reads scenarios from providers.yaml instead of hardcoding them. Update validation to check for testScenario in providers.yaml instead of grepping test-agent-scenario.sh. Co-authored-by: Cursor --- scripts/validate-provider.sh | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/validate-provider.sh b/scripts/validate-provider.sh index fd74d43..1892b9b 100755 --- a/scripts/validate-provider.sh +++ b/scripts/validate-provider.sh @@ -212,20 +212,28 @@ validate_integration() { errors+=("$provider not found in README.md Provider Skills table") fi - # Check providers.yaml has entry + # Check providers.yaml has entry with testScenario + # test-agent-scenario.sh now reads scenarios dynamically from providers.yaml if [ -f "$ROOT_DIR/providers.yaml" ]; then if ! grep -q "name: $provider_name" "$ROOT_DIR/providers.yaml"; then errors+=("$provider_name not found in providers.yaml") + else + # Check that the provider has a testScenario defined + # Use awk to find the provider block and check for testScenario + local has_test_scenario + has_test_scenario=$(awk -v provider="$provider_name" ' + /^ - name:/ { in_provider = ($3 == provider) } + in_provider && /testScenario:/ { print "yes"; exit } + /^ - name:/ && !($3 == provider) { in_provider = 0 } + ' "$ROOT_DIR/providers.yaml") + if [ "$has_test_scenario" != "yes" ]; then + errors+=("No testScenario for $provider_name in providers.yaml") + fi fi else errors+=("providers.yaml not found at repository root") fi - # Check test-agent-scenario.sh has at least one scenario - if ! grep -q "$provider_name" "$ROOT_DIR/scripts/test-agent-scenario.sh"; then - errors+=("No scenario for $provider_name in scripts/test-agent-scenario.sh") - fi - # Return errors if [ ${#errors[@]} -gt 0 ]; then printf '%s\n' "${errors[@]}" @@ -324,8 +332,7 @@ if [ ${#FAILED_PROVIDERS[@]} -gt 0 ]; then log "Please ensure you have updated:" log " 1. All required skill files (SKILL.md, references/, examples/)" log " 2. README.md - Add provider to Provider Skills table" - log " 3. providers.yaml - Add provider entry with documentation URLs" - log " 4. scripts/test-agent-scenario.sh - Add at least one test scenario" + log " 3. providers.yaml - Add provider entry with documentation URLs and testScenario" exit 1 else log "${GREEN}All ${#PASSED_PROVIDERS[@]} provider(s) passed validation!${NC}" From 3c36ee348d6cd4f7e74929842b9c8e6e59fa17f5 Mon Sep 17 00:00:00 2001 From: Phil Leggetter Date: Thu, 5 Feb 2026 18:36:42 +0000 Subject: [PATCH 3/3] feat: add cursor provider integration - Add cursor to providers.yaml with testScenario - Add cursor to README.md Provider Skills table Co-authored-by: Cursor --- README.md | 1 + providers.yaml | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 7942d3d..7c4e2bc 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ |----------|-------|--------------| | Chargebee | [`chargebee-webhooks`](skills/chargebee-webhooks/) | Receive and verify Chargebee webhooks (Basic Auth), handle subscription billing events | | Clerk | [`clerk-webhooks`](skills/clerk-webhooks/) | Verify Clerk webhook signatures, handle user, session, and organization events | +| Cursor | [`cursor-webhooks`](skills/cursor-webhooks/) | Verify Cursor Cloud Agent webhook signatures, handle agent status events | | Deepgram | [`deepgram-webhooks`](skills/deepgram-webhooks/) | Receive and verify Deepgram transcription callbacks | | ElevenLabs | [`elevenlabs-webhooks`](skills/elevenlabs-webhooks/) | Verify ElevenLabs webhook signatures, handle call transcription events | | FusionAuth | [`fusionauth-webhooks`](skills/fusionauth-webhooks/) | Verify FusionAuth JWT webhook signatures, handle user, login, and registration events | diff --git a/providers.yaml b/providers.yaml index dd6f532..e7ab968 100644 --- a/providers.yaml +++ b/providers.yaml @@ -59,6 +59,19 @@ providers: - user.created - session.created + - name: cursor + displayName: Cursor + docs: + webhooks: https://docs.cursor.com/account/cloud-agent-webhooks + notes: > + AI code editor. Uses x-webhook-signature header with HMAC-SHA256 (hex encoded). + Signature format is sha256=. Cloud Agent webhooks notify of + agent status changes (ERROR, FINISHED). + testScenario: + events: + - FINISHED + - ERROR + - name: deepgram displayName: Deepgram docs: