From 5b7a451a635ffd1eb90ad66d063d1200adb8221c Mon Sep 17 00:00:00 2001 From: chitcommit Date: Sun, 14 Jun 2026 12:04:23 +0000 Subject: [PATCH 1/2] feat(queue): connection-only hand-off of GH events to chittyagent-dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the 'MCP bus dispatch deferred to v3' seam in processEvent, forward the normalized event to the routing layer (chittyagent-dispatch) for the PR-shaped event types: pull_request, pull_request_review, pull_request_review_thread, check_suite, check_run. CONNECTION layer only — no maintainer or Linear logic here. ChittyConnect owns the GitHub App, HMAC verify, App token, and webhook receipt; it only hands off. Reaches dispatch via the AGENT_DISPATCH service binding if present, else fetches DISPATCH_URL (default https://dispatch.chitty.cc) using the existing X-Webhook-* header style (incl. INTERNAL_WEBHOOK_SECRET when set). Fail-safe: the webhook is already fast-acked, so the hand-off never throws — it logs and continues. Inert until the AGENT_DISPATCH binding or DISPATCH_URL is configured. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/handlers/queue.js | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/handlers/queue.js b/src/handlers/queue.js index 12e1661..6b08973 100755 --- a/src/handlers/queue.js +++ b/src/handlers/queue.js @@ -120,6 +120,15 @@ async function processEvent(message, env) { tenantId: mcpEvent.tenantId, }); + // CONNECTION-ONLY hand-off to chittyagent-dispatch (routing layer). + // ChittyConnect owns the GitHub App, HMAC verify, App token, and webhook + // receipt — it does NOT own maintainer/Linear logic. For the PR-shaped + // event types below we forward the normalized event to the dispatcher, + // which fans it out to its consumers (autoassist gh_maintainer, linear_sync). + // Fail-safe: the webhook was already fast-acked upstream, so this hand-off + // must never throw — failures are logged and processing continues. + await forwardToDispatch(env, event, mcpEvent); + // Execute v1 automations based on event type await runAutomations(env, event, payload, installationId); @@ -146,6 +155,89 @@ async function processEvent(message, env) { } } +// GitHub event types eligible for the dispatch hand-off. Scoped to the +// PR lifecycle surface the routing layer's consumers care about. Other +// event types (push, issue_comment, …) stay on the internal v1 path only. +const DISPATCH_EVENT_TYPES = new Set([ + "pull_request", + "pull_request_review", + "pull_request_review_thread", + "check_suite", + "check_run", +]); + +/** + * Connection-only hand-off of a normalized GitHub event to chittyagent-dispatch. + * + * This is the seam between the CONNECTION layer (ChittyConnect) and the ROUTING + * layer (chittyagent-dispatch). No maintainer or Linear logic lives here — we + * only forward the normalized event so the dispatcher can fan it out. + * + * Reachability: prefers the AGENT_DISPATCH service binding if present, else + * falls back to fetching env.DISPATCH_URL (default https://dispatch.chitty.cc). + * Mirrors webhook-router.js's X-Webhook-* header style and includes + * env.INTERNAL_WEBHOOK_SECRET when configured. + * + * Fail-safe: never throws. The webhook is already fast-acked, so a dispatch + * outage must not break event processing — we log and continue. + * + * @param {object} env - Worker environment + * @param {string} event - Raw GitHub event name (e.g. "pull_request") + * @param {object} mcpEvent - Normalized event from normalizeGitHubEvent + * @returns {Promise} + */ +async function forwardToDispatch(env, event, mcpEvent) { + if (!DISPATCH_EVENT_TYPES.has(event)) return; + + const timestamp = new Date().toISOString(); + const headers = { + "Content-Type": "application/json", + "X-Webhook-Source": "github", + "X-Webhook-Timestamp": timestamp, + "X-Forwarded-By": "chittyconnect", + ...(env.INTERNAL_WEBHOOK_SECRET && { + "X-Webhook-Secret": env.INTERNAL_WEBHOOK_SECRET, + }), + }; + const body = JSON.stringify(mcpEvent); + + try { + let response; + if (env.AGENT_DISPATCH && typeof env.AGENT_DISPATCH.fetch === "function") { + // Service binding: host is ignored, path is what matters. + response = await env.AGENT_DISPATCH.fetch( + new Request("https://internal/dispatch/github", { + method: "POST", + headers, + body, + }), + ); + } else { + const base = env.DISPATCH_URL || "https://dispatch.chitty.cc"; + response = await fetch(`${base}/dispatch/github`, { + method: "POST", + headers, + body, + }); + } + + console.log("Dispatch hand-off:", { + type: mcpEvent.type, + delivery: mcpEvent.delivery, + transport: env.AGENT_DISPATCH ? "binding" : "fetch", + status: response?.status, + ok: response?.ok, + }); + } catch (error) { + // Fail-safe: log and continue. The webhook is already acked. + console.error("Dispatch hand-off failed (continuing):", { + type: mcpEvent.type, + delivery: mcpEvent.delivery, + error: error.message, + }); + } +} + /** * Lookup tenant ID from installation ID * @param {object} env From 624451178e967a974377179d3730e33902ee87e4 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:45:29 +0000 Subject: [PATCH 2/2] fix(queue): timeout, secret-scoping, and accurate transport label on dispatch hand-off Address Copilot review on PR #252: - add AbortSignal.timeout(5s) to both binding and fetch dispatch calls so a hung dispatch can't stall the queue consumer and back up v1 automations - only forward X-Webhook-Secret to trusted hosts (*.chitty.cc / localhost) on the DISPATCH_URL fallback path, so a misconfigured URL can't leak the shared internal secret; binding path stays trusted - compute transport label from the same predicate as the request branch so the log can't claim "binding" while actually using the fetch fallback Co-Authored-By: Claude Opus 4.8 (1M context) --- src/handlers/queue.js | 61 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/src/handlers/queue.js b/src/handlers/queue.js index 6b08973..bc53c7c 100755 --- a/src/handlers/queue.js +++ b/src/handlers/queue.js @@ -186,45 +186,88 @@ const DISPATCH_EVENT_TYPES = new Set([ * @param {object} mcpEvent - Normalized event from normalizeGitHubEvent * @returns {Promise} */ +/** + * Whether a dispatch base URL points at a host we trust with the shared + * internal webhook secret: *.chitty.cc, chitty.cc, or localhost for dev. + * @param {string} base + * @returns {boolean} + */ +function isTrustedDispatchHost(base) { + try { + const host = new URL(base).hostname.toLowerCase(); + return ( + host === "chitty.cc" || + host.endsWith(".chitty.cc") || + host === "localhost" || + host === "127.0.0.1" + ); + } catch { + return false; + } +} + async function forwardToDispatch(env, event, mcpEvent) { if (!DISPATCH_EVENT_TYPES.has(event)) return; const timestamp = new Date().toISOString(); - const headers = { + // Same predicate the request branch uses, so the log label below can't claim + // "binding" when AGENT_DISPATCH lacks a fetch fn and we actually fall back. + const useBinding = + env.AGENT_DISPATCH && typeof env.AGENT_DISPATCH.fetch === "function"; + const baseHeaders = { "Content-Type": "application/json", "X-Webhook-Source": "github", "X-Webhook-Timestamp": timestamp, "X-Forwarded-By": "chittyconnect", - ...(env.INTERNAL_WEBHOOK_SECRET && { - "X-Webhook-Secret": env.INTERNAL_WEBHOOK_SECRET, - }), }; const body = JSON.stringify(mcpEvent); + // Dispatch can be slow/hung; without a timeout the queue consumer stalls and + // backs up v1 automations. Keep the fail-safe property meaningful. + const DISPATCH_TIMEOUT_MS = 5000; try { let response; - if (env.AGENT_DISPATCH && typeof env.AGENT_DISPATCH.fetch === "function") { - // Service binding: host is ignored, path is what matters. + if (useBinding) { + // Service binding: host is ignored, path is what matters. The binding + // only resolves to the trusted internal dispatch worker, so the shared + // secret is safe to send here. response = await env.AGENT_DISPATCH.fetch( new Request("https://internal/dispatch/github", { method: "POST", - headers, + headers: { + ...baseHeaders, + ...(env.INTERNAL_WEBHOOK_SECRET && { + "X-Webhook-Secret": env.INTERNAL_WEBHOOK_SECRET, + }), + }, body, + signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS), }), ); } else { const base = env.DISPATCH_URL || "https://dispatch.chitty.cc"; + // Only forward the shared internal secret to trusted hosts. A + // misconfigured DISPATCH_URL pointing at a non-trusted host must not leak + // INTERNAL_WEBHOOK_SECRET. + const trusted = isTrustedDispatchHost(base); response = await fetch(`${base}/dispatch/github`, { method: "POST", - headers, + headers: { + ...baseHeaders, + ...(env.INTERNAL_WEBHOOK_SECRET && + trusted && { + "X-Webhook-Secret": env.INTERNAL_WEBHOOK_SECRET, + }), + }, body, + signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS), }); } console.log("Dispatch hand-off:", { type: mcpEvent.type, delivery: mcpEvent.delivery, - transport: env.AGENT_DISPATCH ? "binding" : "fetch", + transport: useBinding ? "binding" : "fetch", status: response?.status, ok: response?.ok, });