diff --git a/CLAUDE.md b/CLAUDE.md index a8a77ddad..8e712bb8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,8 @@ cascade projects trigger-set --agent --event --enabl Some triggers take params (e.g. `review` + `scm:check-suite-success` accepts `{"authorMode":"own"|"external"}`). Legacy configs on `project_integrations.triggers` are auto-migrated on merge to `dev`/`main`. +**External webhook trigger** — `internal:external-webhook` lets any third-party system dispatch an agent via `POST {router}/external/webhook/:projectId/:agentType` with `Authorization: Bearer `. The password is the `EXTERNAL_WEBHOOK_PASSWORD_` project credential (org inheritance applies; server enforces ≥16 chars); authentication **fails closed** — unknown project, unset password, and wrong token all return an identical generic 401 (anti-enumeration; distinct decision reasons only in `webhook_logs`), and failed attempts are IP-rate-limited via the shared sliding-window limiter. The POST body (≤64 KiB, read with an incremental cap) reaches the agent as its work request — `appendExternalTriggerRequest` appends it to the rendered task prompt centrally, gated on `triggerType === 'external-webhook'`. Dispatch rides the existing manual-run dashboard-job path (`createQueuedRun` + `submitDashboardJob`), so runs appear as trigger `external-webhook`. **Security invariant:** `EXTERNAL_WEBHOOK_PASSWORD_*` keys are inbound-auth verifiers and are excluded from worker container env injection (`src/router/worker-env.ts`) — an agent must never see them. Enable per agent in the project's Agents → Triggers tab, which displays the URL and password field (`src/router/external-webhook.ts`, helpers at `src/triggers/shared/external-webhook.ts`). + **Work-item concurrency lock** — the router prevents duplicate agent runs via a per-agent-type lock on `(projectId, workItemId, agentType)`. Only same-type duplicates are blocked; **different agent types can run concurrently** on the same work item (e.g. review starts while implementation's container is still cleaning up). The lock has a 30-minute TTL hard ceiling that auto-clears stale entries after router restart. **Implementation freshness gate** — MNG-1053. PM router adapters intentionally embed a pre-resolved `TriggerResult` for delayed/coalesced PM jobs, so the work-item lock alone cannot prevent a stale implementation snapshot from running. The shared execution pipeline at `src/triggers/shared/agent-execution.ts` now runs a worker-side freshness gate (`src/triggers/shared/implementation-freshness-gate.ts`) before `persistAgentWorkItemLinks()` / `prepareForAgent()`. The gate only fires for `agentType === 'implementation'` with a resolved `workItemId` — review/respond-to-* and follow-up agents bypass it. It reloads live PM work-item state and terminal checklists (`Implementation Steps`, `Acceptance Criteria`), counts active same-type runs, and verifies linked PRs by resolving the implementer GitHub persona token before calling `githubClient.getPR()` (so manual/retry pipeline callers do not depend on ambient GitHub scope). Open or merged PRs and fully-complete terminal checklists block dispatch with a durable `Implementation not started:` PM comment (updating the existing ack comment when present). Checklist read uncertainty always falls into `needs_human_reconciliation`; PR lookup uncertainty does the same when a DB/run-linked PR candidate exists. Closed-unmerged PRs do NOT permanently block reimplementation. diff --git a/docs/architecture/03-trigger-system.md b/docs/architecture/03-trigger-system.md index f5d6900f6..49a89ea7a 100644 --- a/docs/architecture/03-trigger-system.md +++ b/docs/architecture/03-trigger-system.md @@ -149,7 +149,7 @@ Triggers use category-prefixed events from `src/triggers/shared/events.ts`. `TRI - PM: `pm:status-changed`, `pm:label-added`, `pm:comment-mention` - SCM: `scm:check-suite-success`, `scm:check-suite-failure`, `scm:pr-review-submitted`, `scm:review-requested`, `scm:pr-opened`, `scm:pr-comment-mention`, `scm:pr-merged`, `scm:pr-ready-to-merge`, `scm:pr-conflict-detected` - Alerting: `alerting:issue-alert`, `alerting:metric-alert`, `alerting:issue-lifecycle` -- Internal: `internal:auto-chain` +- Internal: `internal:auto-chain`, `internal:external-webhook` New handlers should import `TRIGGER_EVENTS` instead of adding raw string literals. The static guard in `tests/unit/triggers/trigger-event-consistency.test.ts` fails when a handler gates on one event string and emits a different `agentInput.triggerEvent`. diff --git a/src/agents/definitions/alerting.yaml b/src/agents/definitions/alerting.yaml index 601be8271..15cc02e4a 100644 --- a/src/agents/definitions/alerting.yaml +++ b/src/agents/definitions/alerting.yaml @@ -49,6 +49,13 @@ triggers: defaultEnabled: true providers: [sentry] contextPipeline: [alertingIssue, directoryListing, contextFiles] + - event: internal:external-webhook + label: External Webhook + description: >- + Dispatch this agent from an external system via an authenticated POST to + the project's per-agent webhook URL. The request body reaches the agent + as trigger context. + defaultEnabled: false strategies: {} diff --git a/src/agents/definitions/backlog-manager.yaml b/src/agents/definitions/backlog-manager.yaml index 26f7692ca..450c214c9 100644 --- a/src/agents/definitions/backlog-manager.yaml +++ b/src/agents/definitions/backlog-manager.yaml @@ -54,6 +54,13 @@ triggers: description: When splitting completes on a card with the auto label, immediately chain to backlog manager defaultEnabled: false contextPipeline: [pipelineSnapshot] + - event: internal:external-webhook + label: External Webhook + description: >- + Dispatch this agent from an external system via an authenticated POST to + the project's per-agent webhook URL. The request body reaches the agent + as trigger context. + defaultEnabled: false # Required context — runs for EVERY invocation regardless of trigger source. # Manual `cascade runs trigger --agent-type backlog-manager` would otherwise diff --git a/src/agents/definitions/implementation.yaml b/src/agents/definitions/implementation.yaml index bcace71a7..dd5d2532d 100644 --- a/src/agents/definitions/implementation.yaml +++ b/src/agents/definitions/implementation.yaml @@ -58,6 +58,13 @@ triggers: options: [todo] defaultValue: todo contextPipeline: [directoryListing, contextFiles, workItem, prepopulateTodos] + - event: internal:external-webhook + label: External Webhook + description: >- + Dispatch this agent from an external system via an authenticated POST to + the project's per-agent webhook URL. The request body reaches the agent + as trigger context. + defaultEnabled: false strategies: {} diff --git a/src/agents/definitions/planning.yaml b/src/agents/definitions/planning.yaml index 8c37c505f..5e57ab851 100644 --- a/src/agents/definitions/planning.yaml +++ b/src/agents/definitions/planning.yaml @@ -55,6 +55,13 @@ triggers: options: [planning] defaultValue: planning contextPipeline: [directoryListing, contextFiles, workItem] + - event: internal:external-webhook + label: External Webhook + description: >- + Dispatch this agent from an external system via an authenticated POST to + the project's per-agent webhook URL. The request body reaches the agent + as trigger context. + defaultEnabled: false strategies: {} hooks: diff --git a/src/agents/definitions/profiles.ts b/src/agents/definitions/profiles.ts index 009b73dfe..35d482e19 100644 --- a/src/agents/definitions/profiles.ts +++ b/src/agents/definitions/profiles.ts @@ -15,6 +15,7 @@ import { } from '../capabilities/resolver.js'; import type { ContextInjection, ToolManifest } from '../contracts/index.js'; import { + appendExternalTriggerRequest, buildTaskPromptContext, renderInlineTaskPrompt, validateTemplate, @@ -255,7 +256,10 @@ function buildProfileFromDefinition(def: AgentDefinition, agentType: string): Ag return injections; }, buildTaskPrompt: (input) => - renderInlineTaskPrompt(taskPromptTemplate, buildTaskPromptContext(input)), + appendExternalTriggerRequest( + renderInlineTaskPrompt(taskPromptTemplate, buildTaskPromptContext(input)), + input, + ), capabilities: def.capabilities, getLlmistGadgets: (integrationChecker?: IntegrationChecker) => { // Resolve effective capabilities based on integration availability diff --git a/src/agents/definitions/resolve-conflicts.yaml b/src/agents/definitions/resolve-conflicts.yaml index ded107124..41f667e51 100644 --- a/src/agents/definitions/resolve-conflicts.yaml +++ b/src/agents/definitions/resolve-conflicts.yaml @@ -32,6 +32,13 @@ triggers: defaultEnabled: false providers: [github, gitlab] contextPipeline: [prContext, directoryListing, contextFiles, workItem] + - event: internal:external-webhook + label: External Webhook + description: >- + Dispatch this agent from an external system via an authenticated POST to + the project's per-agent webhook URL. The request body reaches the agent + as trigger context. + defaultEnabled: false strategies: {} diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index 849a88f04..34c82215d 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -57,6 +57,13 @@ triggers: options: [own, external, all] defaultValue: own contextPipeline: [prContext, contextFiles, workItem] + - event: internal:external-webhook + label: External Webhook + description: >- + Dispatch this agent from an external system via an authenticated POST to + the project's per-agent webhook URL. The request body reaches the agent + as trigger context. + defaultEnabled: false strategies: {} prompts: diff --git a/src/agents/definitions/splitting.yaml b/src/agents/definitions/splitting.yaml index 534363e2d..90403d928 100644 --- a/src/agents/definitions/splitting.yaml +++ b/src/agents/definitions/splitting.yaml @@ -56,6 +56,13 @@ triggers: options: [splitting] defaultValue: splitting contextPipeline: [directoryListing, contextFiles, workItem] + - event: internal:external-webhook + label: External Webhook + description: >- + Dispatch this agent from an external system via an authenticated POST to + the project's per-agent webhook URL. The request body reaches the agent + as trigger context. + defaultEnabled: false strategies: {} diff --git a/src/agents/prompts/index.ts b/src/agents/prompts/index.ts index 3e0be9d1d..758ca19e5 100644 --- a/src/agents/prompts/index.ts +++ b/src/agents/prompts/index.ts @@ -245,6 +245,37 @@ export function buildTaskPromptContext(input: TaskPromptInput): TaskPromptContex }; } +/** + * Append the external webhook request payload to a rendered task prompt. + * + * The external webhook trigger's core promise is that the POST body reaches + * the agent as its work request — but only the respond-to-* task prompts + * render the commentBody template variable. Rather than editing every agent's + * task prompt, this appends a standard section centrally, gated on the + * external-webhook trigger type so no other dispatch path is affected. + */ +export function appendExternalTriggerRequest( + renderedTaskPrompt: string, + input: { triggerType?: string; triggerCommentBody?: string }, +): string { + if (input.triggerType !== 'external-webhook' || !input.triggerCommentBody) { + return renderedTaskPrompt; + } + return [ + renderedTaskPrompt, + '', + '## External trigger request', + '', + 'This run was dispatched by an external system via webhook. The request', + 'payload below describes what this run should accomplish — treat it as', + 'the work request:', + '', + '', + input.triggerCommentBody, + '', + ].join('\n'); +} + /** * Render an inline task prompt template with Eta variable interpolation. * Used for task prompts stored directly in agent definitions (prompts.taskPrompt). diff --git a/src/api/routers/_shared/triggerTypes.ts b/src/api/routers/_shared/triggerTypes.ts index dbf2ce1dc..1f6162c47 100644 --- a/src/api/routers/_shared/triggerTypes.ts +++ b/src/api/routers/_shared/triggerTypes.ts @@ -234,5 +234,12 @@ export const TRIGGER_REGISTRY: Record = { description: 'Orchestration trigger for chaining agents after completion', contextPipeline: [], }, + { + event: 'internal:external-webhook', + label: 'External Webhook', + description: + 'Dispatch from an external system via an authenticated POST to the per-agent webhook URL', + contextPipeline: [], + }, ], }; diff --git a/src/api/routers/_shared/webhookPasswordPolicy.ts b/src/api/routers/_shared/webhookPasswordPolicy.ts new file mode 100644 index 000000000..cfc722518 --- /dev/null +++ b/src/api/routers/_shared/webhookPasswordPolicy.ts @@ -0,0 +1,20 @@ +import { TRPCError } from '@trpc/server'; +import { isExternalWebhookPasswordKey } from '../../../triggers/shared/external-webhook.js'; + +export const EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH = 16; + +/** + * External webhook passwords authenticate an unauthenticated internet-facing + * endpoint that dispatches agents — a guessable password is remote agent + * execution. Enforce a minimum length at every write path (project + org + * credential mutations). Other credential keys are unaffected. + */ +export function assertWebhookPasswordStrength(envVarKey: string, value: string): void { + if (!isExternalWebhookPasswordKey(envVarKey)) return; + if (value.length < EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Webhook passwords must be at least ${EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH} characters`, + }); + } +} diff --git a/src/api/routers/organization.ts b/src/api/routers/organization.ts index 7edd15d4c..f25f438d1 100644 --- a/src/api/routers/organization.ts +++ b/src/api/routers/organization.ts @@ -15,6 +15,7 @@ import { captureException } from '../../sentry.js'; import { adminProcedure, protectedProcedure, router, superAdminProcedure } from '../trpc.js'; import { maskCredentialValue } from './_shared/maskCredential.js'; import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js'; +import { assertWebhookPasswordStrength } from './_shared/webhookPasswordPolicy.js'; export const organizationRouter = router({ get: protectedProcedure.query(async ({ ctx }) => { @@ -99,6 +100,7 @@ export const organizationRouter = router({ ) .mutation(async ({ ctx, input }) => { assertOrgAdmin(await resolveActorRole(ctx)); + assertWebhookPasswordStrength(input.envVarKey, input.value); await writeOrgCredential( ctx.effectiveOrgId, input.envVarKey, diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index 0de784f67..55cacb45b 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -42,6 +42,7 @@ import { captureException } from '../../sentry.js'; import { logger } from '../../utils/logging.js'; import { protectedProcedure, publicProcedure, router, superAdminProcedure } from '../trpc.js'; import { maskCredentialValue } from './_shared/maskCredential.js'; +import { assertWebhookPasswordStrength } from './_shared/webhookPasswordPolicy.js'; /** * The current worker-image/dockerfile state read alongside the ownership check. @@ -921,6 +922,7 @@ export const projectsRouter = router({ ) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); + assertWebhookPasswordStrength(input.envVarKey, input.value); await writeProjectCredential( input.projectId, input.envVarKey, diff --git a/src/queue/client.ts b/src/queue/client.ts index 2bb8ea92e..6c6ab4d5d 100644 --- a/src/queue/client.ts +++ b/src/queue/client.ts @@ -27,6 +27,13 @@ export interface ManualRunJob { triggerCommentUrl?: string; triggerCommentPath?: string; triggerCommentAuthor?: string; + /** + * Trigger provenance for non-dashboard callers of the manual-run path + * (e.g. the router's external webhook endpoint). Defaults to 'manual'. + */ + triggerType?: 'manual' | 'external-webhook'; + /** Canonical trigger event (e.g. 'internal:external-webhook') for observability. */ + triggerEvent?: string; /** * MNG-1695: id of the `status='queued'` run row pre-created at tRPC trigger * time. The worker activates it (queued → running) instead of inserting a new diff --git a/src/router/external-webhook.ts b/src/router/external-webhook.ts new file mode 100644 index 000000000..44b010418 --- /dev/null +++ b/src/router/external-webhook.ts @@ -0,0 +1,243 @@ +/** + * External webhook endpoint — dispatch an agent from any third-party system. + * + * POST /external/webhook/:projectId/:agentType + * Authorization: Bearer + * + * The password lives in project_credentials under + * EXTERNAL_WEBHOOK_PASSWORD_ (org-credential inheritance applies). + * Authentication FAILS CLOSED: no stored password → every request is rejected. + * The POST body (capped at 64 KiB) reaches the agent as trigger context via + * the manual-run path's triggerCommentBody. + * + * Hardening notes (adversarial review, 2026-08-02): + * - All pre-auth failures (unknown project, no password configured, wrong + * token) return an identical generic 401 so unauthenticated callers cannot + * enumerate projects or distinguish password-armed agents. The distinct + * decisionReasons live only in webhook_logs (superadmin surface). + * - Failed attempts are rate-limited per client IP via the shared sliding + * window limiter; successful auth resets the counter. + * - The Bearer scheme is matched case-insensitively (RFC 7235). + * - The body is read incrementally and aborted at the cap, not buffered first. + * - Error responses never include internal error details; those go to logs. + * - The Authorization header is never logged. + * + * Deliberately hand-rolled rather than built on createWebhookHandler: that + * factory parses before verifying and treats a missing secret as "skip + * verification" (fail open) — both wrong for a token-authenticated endpoint. + */ + +import { timingSafeEqual } from 'node:crypto'; +import type { Context, Handler } from 'hono'; +import { checkRateLimit, recordSuccessfulLogin } from '../api/auth/rateLimiter.js'; +import { resolveEngineName } from '../backends/resolution.js'; +import { loadProjectConfigById } from '../config/provider.js'; +import { resolveProjectCredential } from '../db/repositories/credentialsRepository.js'; +import { createQueuedRun, failQueuedOrRunningRun } from '../db/repositories/runsRepository.js'; +import { submitDashboardJob } from '../queue/client.js'; +import { getResolvedTriggerConfig } from '../triggers/config-resolver.js'; +import { + EXTERNAL_WEBHOOK_EVENT, + externalWebhookCredentialKey, + isValidAgentTypeSlug, +} from '../triggers/shared/external-webhook.js'; +import { logger } from '../utils/logging.js'; +import { logWebhookCall } from '../utils/webhookLogger.js'; + +/** Kept below the 96 KiB JOB_DATA inline threshold (src/router/job-data-offload.ts). */ +export const EXTERNAL_WEBHOOK_BODY_MAX_BYTES = 64 * 1024; + +const UNAUTHORIZED_MESSAGE = 'Unauthorized'; + +function timingSafeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + +/** RFC 7235: the auth scheme token is case-insensitive. */ +function parseBearerToken(authorizationHeader: string): string { + const match = /^bearer\s+(.+)$/i.exec(authorizationHeader); + return match?.[1] ?? ''; +} + +function getClientIp(c: Context): string { + const forwarded = c.req.header('x-forwarded-for'); + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + return 'unknown'; +} + +/** + * Read the request body incrementally, aborting as soon as the byte count + * exceeds the cap — an oversized or endless body never gets fully buffered. + * Returns null when the cap was exceeded. + */ +async function readBodyWithCap(c: Context, maxBytes: number): Promise { + const stream = c.req.raw.body; + if (!stream) return ''; + + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks).toString('utf8'); +} + +interface ReplyExtras { + runId?: string; + bodyRaw?: string; + /** Override the response body message (responses default to decisionReason). */ + publicMessage?: string; +} + +function reply( + c: Context, + status: 200 | 401 | 404 | 413 | 429 | 500, + decisionReason: string, + extras: ReplyExtras = {}, +) { + // Headers deliberately omitted from the log — the Authorization header + // carries the webhook password. Body is logged only on success. + logWebhookCall({ + source: 'external', + method: 'POST', + path: c.req.path, + bodyRaw: status === 200 ? extras.bodyRaw : undefined, + statusCode: status, + projectId: c.req.param('projectId'), + eventType: EXTERNAL_WEBHOOK_EVENT, + processed: status === 200, + decisionReason, + }); + if (status === 200) { + return c.json({ ok: true, runId: extras.runId }, 200); + } + return c.json({ error: extras.publicMessage ?? decisionReason }, status); +} + +/** Identical public 401 for every pre-auth failure — anti-enumeration. */ +function unauthorized(c: Context, decisionReason: string) { + return reply(c, 401, decisionReason, { publicMessage: UNAUTHORIZED_MESSAGE }); +} + +export function createExternalWebhookHandler(): Handler { + return async (c) => { + const projectId = c.req.param('projectId') ?? ''; + const agentType = c.req.param('agentType') ?? ''; + + // 0. Per-IP sliding-window rate limit — every attempt counts until a + // successful authentication resets the counter. + const rateKey = `external-webhook:${getClientIp(c)}`; + const rateCheck = checkRateLimit(rateKey); + if (rateCheck.limited) { + c.header('Retry-After', String(rateCheck.retryAfterSeconds)); + return reply(c, 429, 'Rate limited', { + publicMessage: 'Too many attempts. Please try again later.', + }); + } + + // 1. Cheap slug guard before any DB hit (format error — no state info) + if (!projectId || !isValidAgentTypeSlug(agentType)) { + return reply(c, 404, 'Invalid agent type'); + } + + // 2-4. Authentication. Unknown project, missing password (fail closed), + // and wrong token all collapse into the same generic 401; the + // decisionReason in webhook_logs distinguishes them for operators. + const pc = await loadProjectConfigById(projectId); + if (!pc) { + return unauthorized(c, 'Unknown project'); + } + + const password = await resolveProjectCredential( + projectId, + externalWebhookCredentialKey(agentType), + ); + if (!password) { + return unauthorized( + c, + 'No webhook password configured for this agent — rejecting (fail closed)', + ); + } + + const token = parseBearerToken(c.req.header('authorization') ?? ''); + if (!token || !timingSafeCompare(token, password)) { + return unauthorized(c, 'Invalid or missing bearer token'); + } + recordSuccessfulLogin(rateKey); + + // 5. Enablement — one resolver call covers: agent_configs row exists, + // definition declares the event, DB config / defaultEnabled. The + // caller is authenticated at this point, so a specific 404 is fine. + const triggerConfig = await getResolvedTriggerConfig( + projectId, + agentType, + EXTERNAL_WEBHOOK_EVENT, + ); + if (!triggerConfig) { + return reply(c, 404, 'Agent not enabled or external-webhook trigger not declared'); + } + if (!triggerConfig.enabled) { + return reply(c, 404, 'external-webhook trigger disabled for this agent'); + } + + // 6. Body read with incremental cap (never fully buffers an oversized body) + const body = await readBodyWithCap(c, EXTERNAL_WEBHOOK_BODY_MAX_BYTES); + if (body === null) { + return reply(c, 413, 'Body too large'); + } + + // 7. Dispatch — exact manual-run pattern (src/api/routers/runs.ts:404-451). + // The router consumes cascade-dashboard-jobs itself and spawns the + // worker container; triggerManualRun re-checks enablement + integrations. + const engine = resolveEngineName(agentType, pc.project); + const runId = await createQueuedRun({ + projectId, + agentType, + engine, + triggerType: 'external-webhook', + }); + try { + await submitDashboardJob({ + type: 'manual-run', + projectId, + agentType, + triggerCommentBody: body.trim() ? body : undefined, + triggerType: 'external-webhook', + triggerEvent: EXTERNAL_WEBHOOK_EVENT, + runId, + }); + } catch (err) { + await failQueuedOrRunningRun(runId, 'Failed to enqueue external webhook run'); + logger.error('External webhook enqueue failed', { + projectId, + agentType, + error: String(err), + }); + return reply(c, 500, `Enqueue failed: ${String(err)}`, { + publicMessage: 'Failed to queue the run', + }); + } + + return reply(c, 200, `Job queued: ${agentType} agent (external webhook)`, { + runId, + bodyRaw: body || undefined, + }); + }; +} diff --git a/src/router/index.ts b/src/router/index.ts index 3465ac162..2fe3dce59 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -31,6 +31,7 @@ import { LinearRouterAdapter } from './adapters/linear.js'; import { SentryRouterAdapter } from './adapters/sentry.js'; import { TrelloRouterAdapter } from './adapters/trello.js'; import { startCancelListener, stopCancelListener } from './cancel-listener.js'; +import { createExternalWebhookHandler } from './external-webhook.js'; import { ROUTER_INSTANCE_ID } from './instance-id.js'; import { getQueueStats } from './queue.js'; import { processRouterWebhook } from './webhook-processor.js'; @@ -203,6 +204,11 @@ app.post( }), ); +// External webhook — dispatch an agent from any third-party system. +// Bearer-authenticated against the EXTERNAL_WEBHOOK_PASSWORD_ project +// credential; fails closed when no password is configured. See src/router/external-webhook.ts. +app.post('/external/webhook/:projectId/:agentType', createExternalWebhookHandler()); + // Linear webhook verification app.get('/linear/webhook', (c) => { return c.text('OK', 200); diff --git a/src/router/worker-env.ts b/src/router/worker-env.ts index 9cd3ebee1..14b076faf 100644 --- a/src/router/worker-env.ts +++ b/src/router/worker-env.ts @@ -10,6 +10,7 @@ import { findProjectByRepo, getAllProjectCredentials } from '../config/provider. import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; import { extractProjectIdFromJobViaRegistry } from '../integrations/pm/_shared/project-id-extractor.js'; import { captureException } from '../sentry.js'; +import { isExternalWebhookPasswordKey } from '../triggers/shared/external-webhook.js'; import { logger } from '../utils/logging.js'; import { routerConfig } from './config.js'; import { @@ -169,13 +170,21 @@ export async function buildWorkerEnvWithProjectId( // Resolve project credentials in the router and set as individual env vars. // NOTE: CREDENTIAL_MASTER_KEY is intentionally NOT passed to workers. + // NOTE: EXTERNAL_WEBHOOK_PASSWORD_* keys are inbound-auth verifiers for the + // router's external webhook endpoint — agents never need them, and exposing + // them to worker containers would give a prompt-injected agent a direct + // "dispatch agent X with prompt Y" primitive (org-level passwords would + // escalate cross-project). They are excluded here. if (projectId) { try { const secrets = await getAllProjectCredentials(projectId); + const injectedKeys: string[] = []; for (const [key, value] of Object.entries(secrets)) { + if (isExternalWebhookPasswordKey(key)) continue; env.push(`${key}=${value}`); + injectedKeys.push(key); } - env.push(`CASCADE_CREDENTIAL_KEYS=${Object.keys(secrets).join(',')}`); + env.push(`CASCADE_CREDENTIAL_KEYS=${injectedKeys.join(',')}`); } catch (err) { logger.warn('[WorkerManager] Failed to resolve credentials for project:', { projectId, diff --git a/src/triggers/README.md b/src/triggers/README.md index 4c758e1e8..b0b26593f 100644 --- a/src/triggers/README.md +++ b/src/triggers/README.md @@ -109,7 +109,7 @@ Use `TRIGGER_EVENTS` from `src/triggers/shared/events.ts` for every new trigger | `PM` | `pm:status-changed`, `pm:label-added`, `pm:comment-mention` | | `SCM` | `scm:check-suite-success`, `scm:check-suite-failure`, `scm:pr-review-submitted`, `scm:review-requested`, `scm:pr-opened`, `scm:pr-comment-mention`, `scm:pr-merged`, `scm:pr-ready-to-merge`, `scm:pr-conflict-detected` | | `ALERTING` | `alerting:issue-alert`, `alerting:metric-alert` | -| `INTERNAL` | `internal:auto-chain` | +| `INTERNAL` | `internal:auto-chain`, `internal:external-webhook` | Do not introduce raw event-string literals in new handlers. If a handler checks `checkTriggerEnabled(..., event, ...)`, the same event must be emitted as `agentInput.triggerEvent`; `tests/unit/triggers/trigger-event-consistency.test.ts` enforces that invariant because mismatches make enabled triggers silently fall back to YAML defaults. diff --git a/src/triggers/shared/events.ts b/src/triggers/shared/events.ts index 043a4e688..911803756 100644 --- a/src/triggers/shared/events.ts +++ b/src/triggers/shared/events.ts @@ -22,6 +22,7 @@ export const TRIGGER_EVENTS = { }, INTERNAL: { AUTO_CHAIN: 'internal:auto-chain', + EXTERNAL_WEBHOOK: 'internal:external-webhook', }, } as const; diff --git a/src/triggers/shared/external-webhook.ts b/src/triggers/shared/external-webhook.ts new file mode 100644 index 000000000..588ad6c2e --- /dev/null +++ b/src/triggers/shared/external-webhook.ts @@ -0,0 +1,51 @@ +/** + * Shared helpers for the external webhook trigger (TRIGGER_EVENTS.INTERNAL.EXTERNAL_WEBHOOK). + * + * External systems dispatch an agent by POSTing to the router at + * `/external/webhook/:projectId/:agentType`, authenticated with a Bearer + * password stored as a project credential. This module is deliberately + * dependency-light: it is imported by the router endpoint AND by the web + * bundle (value-import-from-src precedent: web/src/lib/trigger-agent-mapping.ts). + */ + +import { TRIGGER_EVENTS } from './events.js'; + +export const EXTERNAL_WEBHOOK_EVENT = TRIGGER_EVENTS.INTERNAL.EXTERNAL_WEBHOOK; + +export const EXTERNAL_WEBHOOK_PASSWORD_PREFIX = 'EXTERNAL_WEBHOOK_PASSWORD_'; + +/** + * True for credential keys that hold inbound webhook passwords. These are + * VERIFIERS for requests arriving at the router — agents never need them, and + * injecting them into worker containers would hand any prompt-injected agent + * a "dispatch agent X with prompt Y" primitive (org-level passwords would even + * escalate cross-project). The worker env builder excludes them. + */ +export function isExternalWebhookPasswordKey(envVarKey: string): boolean { + return envVarKey.startsWith(EXTERNAL_WEBHOOK_PASSWORD_PREFIX); +} + +const AGENT_TYPE_SLUG_RE = /^[a-z][a-z0-9-]*$/; + +/** Agent type slugs are lowercase kebab identifiers (e.g. 'backlog-manager'). */ +export function isValidAgentTypeSlug(agentType: string): boolean { + return agentType.length <= 64 && AGENT_TYPE_SLUG_RE.test(agentType); +} + +/** + * The project_credentials env var key holding the webhook password for one + * agent type, e.g. 'implementation' → 'EXTERNAL_WEBHOOK_PASSWORD_IMPLEMENTATION', + * 'backlog-manager' → 'EXTERNAL_WEBHOOK_PASSWORD_BACKLOG_MANAGER'. + * Always matches the credential key pattern /^[A-Z_][A-Z0-9_]*$/. + */ +export function externalWebhookCredentialKey(agentType: string): string { + if (!isValidAgentTypeSlug(agentType)) { + throw new Error(`Invalid agent type slug: ${agentType}`); + } + return `EXTERNAL_WEBHOOK_PASSWORD_${agentType.toUpperCase().replace(/-/g, '_')}`; +} + +/** Router path for one project + agent's external webhook endpoint. */ +export function externalWebhookPath(projectId: string, agentType: string): string { + return `/external/webhook/${projectId}/${agentType}`; +} diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index 035562ac1..ea6746b23 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -77,6 +77,10 @@ export interface ManualTriggerInput { triggerCommentUrl?: string; triggerCommentPath?: string; triggerCommentAuthor?: string; + /** Trigger provenance override (router external webhook endpoint). Defaults to 'manual'. */ + triggerType?: 'manual' | 'external-webhook'; + /** Canonical trigger event (see TRIGGER_EVENTS in shared/events.ts). */ + triggerEvent?: string; /** * MNG-1695: id of a pre-created `status='queued'` run row. Rides the * agentInput to `executeWithEngine` → `tryCreateRun`, which activates it @@ -146,7 +150,8 @@ export async function triggerManualRun( repoFullName: input.repoFullName, headSha: input.headSha, modelOverride: input.modelOverride, - triggerType: 'manual', + triggerType: input.triggerType ?? 'manual', + triggerEvent: input.triggerEvent, triggerCommentBody: input.triggerCommentBody, triggerCommentId: input.triggerCommentId, triggerCommentUrl: input.triggerCommentUrl, diff --git a/src/types/index.ts b/src/types/index.ts index c61cae46e..ac97ae7cf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,7 +27,8 @@ export interface AgentInput { | 'review-requested' | 'pr-opened' | 'conflict-resolution' - | 'manual'; + | 'manual' + | 'external-webhook'; /** YAML-format trigger event name for context pipeline resolution (e.g. 'scm:check-suite-success') */ triggerEvent?: CanonicalTriggerEvent | (string & {}); diff --git a/src/worker-entry.ts b/src/worker-entry.ts index 1199ebb6b..8c4d46e4f 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -170,6 +170,10 @@ export interface ManualRunJobData { triggerCommentUrl?: string; triggerCommentPath?: string; triggerCommentAuthor?: string; + /** Trigger provenance (router external webhook endpoint). Defaults to 'manual'. */ + triggerType?: 'manual' | 'external-webhook'; + /** Canonical trigger event (e.g. 'internal:external-webhook'). */ + triggerEvent?: string; /** MNG-1695: id of the pre-created `queued` run row to activate on boot. */ runId?: string; } @@ -228,6 +232,8 @@ export async function processDashboardJob(jobId: string, jobData: DashboardJobDa triggerCommentUrl: jobData.triggerCommentUrl, triggerCommentPath: jobData.triggerCommentPath, triggerCommentAuthor: jobData.triggerCommentAuthor, + triggerType: jobData.triggerType, + triggerEvent: jobData.triggerEvent, // MNG-1695: activate the pre-created queued row instead of inserting a new one. preCreatedRunId: jobData.runId, }, diff --git a/tests/unit/agents/definitions/external-webhook-trigger.test.ts b/tests/unit/agents/definitions/external-webhook-trigger.test.ts new file mode 100644 index 000000000..4aa33b5f8 --- /dev/null +++ b/tests/unit/agents/definitions/external-webhook-trigger.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + getBuiltinAgentTypes, + loadBuiltinDefinition, +} from '../../../../src/agents/definitions/loader.js'; +import { EXTERNAL_WEBHOOK_EVENT } from '../../../../src/triggers/shared/external-webhook.js'; + +const DECLARING_AGENTS = [ + 'implementation', + 'planning', + 'splitting', + 'backlog-manager', + 'review', + 'resolve-conflicts', + 'alerting', +] as const; + +describe('external webhook trigger declarations', () => { + for (const agentType of DECLARING_AGENTS) { + it(`${agentType} declares internal:external-webhook (opt-in, no params)`, () => { + const definition = loadBuiltinDefinition(agentType); + const trigger = definition.triggers?.find((t) => t.event === EXTERNAL_WEBHOOK_EVENT); + + expect(trigger).toBeDefined(); + expect(trigger?.defaultEnabled).toBe(false); + expect(trigger?.parameters ?? []).toEqual([]); + expect(trigger?.label).toBe('External Webhook'); + }); + } + + it('no other builtin agent declares it', () => { + const declaring = new Set(DECLARING_AGENTS); + for (const agentType of getBuiltinAgentTypes()) { + if (declaring.has(agentType)) continue; + const definition = loadBuiltinDefinition(agentType); + const trigger = definition.triggers?.find((t) => t.event === EXTERNAL_WEBHOOK_EVENT); + expect(trigger, `${agentType} should not declare external-webhook`).toBeUndefined(); + } + }); +}); diff --git a/tests/unit/agents/definitions/profiles.test.ts b/tests/unit/agents/definitions/profiles.test.ts index 3cced97f8..efad10cb5 100644 --- a/tests/unit/agents/definitions/profiles.test.ts +++ b/tests/unit/agents/definitions/profiles.test.ts @@ -22,6 +22,8 @@ vi.mock('../../../../src/agents/capabilities/resolver.js', () => ({ vi.mock('../../../../src/agents/prompts/index.js', () => ({ buildTaskPromptContext: vi.fn().mockReturnValue({ task: 'implement' }), renderInlineTaskPrompt: vi.fn().mockReturnValue('Rendered task prompt'), + // Pass-through like the real implementation for non-external-webhook input + appendExternalTriggerRequest: vi.fn((prompt: string) => prompt), validateTemplate: vi.fn().mockReturnValue({ valid: true }), })); diff --git a/tests/unit/agents/prompts/external-trigger-request.test.ts b/tests/unit/agents/prompts/external-trigger-request.test.ts new file mode 100644 index 000000000..1cb0dc900 --- /dev/null +++ b/tests/unit/agents/prompts/external-trigger-request.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { appendExternalTriggerRequest } from '../../../../src/agents/prompts/index.js'; + +describe('appendExternalTriggerRequest', () => { + it('appends the request payload for external-webhook runs', () => { + const result = appendExternalTriggerRequest('Base task prompt.', { + triggerType: 'external-webhook', + triggerCommentBody: '{"message":"fix the login bug"}', + }); + + expect(result).toContain('Base task prompt.'); + expect(result).toContain('## External trigger request'); + expect(result).toContain(''); + expect(result).toContain('{"message":"fix the login bug"}'); + expect(result).toContain(''); + }); + + it('is a no-op for manual runs even with a comment body', () => { + const result = appendExternalTriggerRequest('Base task prompt.', { + triggerType: 'manual', + triggerCommentBody: 'some comment', + }); + expect(result).toBe('Base task prompt.'); + }); + + it('is a no-op for external-webhook runs without a body', () => { + const result = appendExternalTriggerRequest('Base task prompt.', { + triggerType: 'external-webhook', + }); + expect(result).toBe('Base task prompt.'); + }); +}); diff --git a/tests/unit/api/routers/_shared/webhookPasswordPolicy.test.ts b/tests/unit/api/routers/_shared/webhookPasswordPolicy.test.ts new file mode 100644 index 000000000..4210161e8 --- /dev/null +++ b/tests/unit/api/routers/_shared/webhookPasswordPolicy.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + assertWebhookPasswordStrength, + EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH, +} from '../../../../../src/api/routers/_shared/webhookPasswordPolicy.js'; + +describe('assertWebhookPasswordStrength', () => { + it('rejects short webhook passwords', () => { + expect(() => + assertWebhookPasswordStrength('EXTERNAL_WEBHOOK_PASSWORD_IMPLEMENTATION', 'short'), + ).toThrow(/at least 16 characters/); + }); + + it('accepts webhook passwords at the minimum length', () => { + expect(() => + assertWebhookPasswordStrength( + 'EXTERNAL_WEBHOOK_PASSWORD_IMPLEMENTATION', + 'x'.repeat(EXTERNAL_WEBHOOK_PASSWORD_MIN_LENGTH), + ), + ).not.toThrow(); + }); + + it('ignores non-webhook credential keys entirely', () => { + expect(() => assertWebhookPasswordStrength('GITHUB_TOKEN_IMPLEMENTER', 'x')).not.toThrow(); + expect(() => assertWebhookPasswordStrength('OPENROUTER_API_KEY', 'a')).not.toThrow(); + }); +}); diff --git a/tests/unit/router/external-webhook.test.ts b/tests/unit/router/external-webhook.test.ts new file mode 100644 index 000000000..cd7b5fce2 --- /dev/null +++ b/tests/unit/router/external-webhook.test.ts @@ -0,0 +1,318 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { _resetForTesting } from '../../../src/api/auth/rateLimiter.js'; + +// Must mock heavy imports BEFORE importing the module under test +const { + mockLoadProjectConfigById, + mockResolveProjectCredential, + mockGetResolvedTriggerConfig, + mockCreateQueuedRun, + mockFailQueuedOrRunningRun, + mockSubmitDashboardJob, + mockResolveEngineName, + mockLogWebhookCall, +} = vi.hoisted(() => ({ + mockLoadProjectConfigById: vi.fn(), + mockResolveProjectCredential: vi.fn(), + mockGetResolvedTriggerConfig: vi.fn(), + mockCreateQueuedRun: vi.fn(), + mockFailQueuedOrRunningRun: vi.fn(), + mockSubmitDashboardJob: vi.fn(), + mockResolveEngineName: vi.fn(), + mockLogWebhookCall: vi.fn(), +})); + +vi.mock('../../../src/config/provider.js', () => ({ + loadProjectConfigById: mockLoadProjectConfigById, +})); +vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ + resolveProjectCredential: mockResolveProjectCredential, +})); +vi.mock('../../../src/triggers/config-resolver.js', () => ({ + getResolvedTriggerConfig: mockGetResolvedTriggerConfig, +})); +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + createQueuedRun: mockCreateQueuedRun, + failQueuedOrRunningRun: mockFailQueuedOrRunningRun, +})); +vi.mock('../../../src/queue/client.js', () => ({ + submitDashboardJob: mockSubmitDashboardJob, +})); +vi.mock('../../../src/backends/resolution.js', () => ({ + resolveEngineName: mockResolveEngineName, +})); +vi.mock('../../../src/utils/webhookLogger.js', () => ({ + logWebhookCall: mockLogWebhookCall, +})); +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() }, +})); + +import { + createExternalWebhookHandler, + EXTERNAL_WEBHOOK_BODY_MAX_BYTES, +} from '../../../src/router/external-webhook.js'; + +function buildApp(): Hono { + const app = new Hono(); + app.post('/external/webhook/:projectId/:agentType', createExternalWebhookHandler()); + return app; +} + +let ipCounter = 0; +/** Unique per-test client IP so the shared rate limiter never couples tests. */ +function nextIp(): string { + ipCounter += 1; + return `10.0.0.${ipCounter}`; +} + +function post( + app: Hono, + { + projectId = 'proj-1', + agentType = 'implementation', + body = '{"message":"do the thing"}', + headers = {} as Record, + ip = nextIp(), + } = {}, +): Promise { + return app.fetch( + new Request(`http://localhost/external/webhook/${projectId}/${agentType}`, { + method: 'POST', + headers: { 'x-forwarded-for': ip, ...headers }, + body, + }), + ); +} + +const bearer = (password: string) => ({ Authorization: `Bearer ${password}` }); + +describe('external webhook endpoint', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetForTesting(); + mockLoadProjectConfigById.mockResolvedValue({ project: { id: 'proj-1' }, config: {} }); + mockResolveProjectCredential.mockResolvedValue('correct-password'); + mockGetResolvedTriggerConfig.mockResolvedValue({ enabled: true, parameters: {} }); + mockResolveEngineName.mockReturnValue('claude-code'); + mockCreateQueuedRun.mockResolvedValue('run-123'); + mockSubmitDashboardJob.mockResolvedValue(undefined); + }); + + describe('fail closed + anti-enumeration', () => { + it('rejects with generic 401 when no password is configured — never dispatches', async () => { + mockResolveProjectCredential.mockResolvedValue(null); + + const res = await post(buildApp(), { headers: bearer('anything') }); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + expect(mockSubmitDashboardJob).not.toHaveBeenCalled(); + expect(mockCreateQueuedRun).not.toHaveBeenCalled(); + }); + + it('unknown project, unset password, and wrong token are indistinguishable to callers', async () => { + const app = buildApp(); + + mockLoadProjectConfigById.mockResolvedValueOnce(undefined); + const unknownProject = await post(app, { headers: bearer('x') }); + + mockResolveProjectCredential.mockResolvedValueOnce(null); + const noPassword = await post(app, { headers: bearer('x') }); + + const wrongToken = await post(app, { headers: bearer('wrong-password!!') }); + + const bodies = await Promise.all( + [unknownProject, noPassword, wrongToken].map(async (r) => ({ + status: r.status, + body: await r.json(), + })), + ); + expect(bodies).toEqual([ + { status: 401, body: { error: 'Unauthorized' } }, + { status: 401, body: { error: 'Unauthorized' } }, + { status: 401, body: { error: 'Unauthorized' } }, + ]); + }); + + it('keeps distinct decision reasons in webhook logs for operators', async () => { + mockLoadProjectConfigById.mockResolvedValueOnce(undefined); + await post(buildApp(), { headers: bearer('x') }); + + const reasons = mockLogWebhookCall.mock.calls.map( + (call) => (call[0] as { decisionReason?: string }).decisionReason, + ); + expect(reasons).toContain('Unknown project'); + }); + }); + + describe('authentication', () => { + it('rejects missing Authorization header with 401', async () => { + const res = await post(buildApp()); + expect(res.status).toBe(401); + }); + + it('rejects non-Bearer scheme with 401', async () => { + const res = await post(buildApp(), { + headers: { Authorization: 'Basic correct-password' }, + }); + expect(res.status).toBe(401); + }); + + it('accepts a case-insensitive bearer scheme (RFC 7235)', async () => { + const res = await post(buildApp(), { + headers: { Authorization: 'bearer correct-password' }, + }); + expect(res.status).toBe(200); + }); + + it('rejects wrong password with 401', async () => { + const res = await post(buildApp(), { headers: bearer('wrong-password!') }); + expect(res.status).toBe(401); + expect(mockSubmitDashboardJob).not.toHaveBeenCalled(); + }); + + it('never passes the Authorization header or password to webhook logs', async () => { + await post(buildApp(), { headers: bearer('correct-password') }); + + for (const call of mockLogWebhookCall.mock.calls) { + const input = call[0] as Record; + expect(input.headers).toBeUndefined(); + expect(JSON.stringify(input)).not.toContain('correct-password'); + } + }); + }); + + describe('rate limiting', () => { + it('returns 429 with Retry-After after repeated attempts from one IP', async () => { + const app = buildApp(); + const ip = nextIp(); + + let lastStatus = 0; + for (let i = 0; i < 12; i++) { + const res = await post(app, { headers: bearer('wrong-password!'), ip }); + lastStatus = res.status; + if (lastStatus === 429) { + expect(res.headers.get('Retry-After')).toBeTruthy(); + break; + } + } + expect(lastStatus).toBe(429); + }); + + it('successful auth resets the counter for the IP', async () => { + const app = buildApp(); + const ip = nextIp(); + + for (let i = 0; i < 5; i++) { + await post(app, { headers: bearer('wrong-password!'), ip }); + } + const success = await post(app, { headers: bearer('correct-password'), ip }); + expect(success.status).toBe(200); + + // Counter reset — several more attempts allowed before limiting again + const next = await post(app, { headers: bearer('wrong-password!'), ip }); + expect(next.status).toBe(401); + }); + }); + + describe('routing guards', () => { + it('returns 404 for an invalid agent type slug', async () => { + const res = await post(buildApp(), { agentType: 'Not-Valid!' }); + expect(res.status).toBe(404); + expect(mockLoadProjectConfigById).not.toHaveBeenCalled(); + }); + + it('returns 404 when the agent is not enabled or trigger undeclared (post-auth)', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue(null); + const res = await post(buildApp(), { headers: bearer('correct-password') }); + expect(res.status).toBe(404); + expect(mockSubmitDashboardJob).not.toHaveBeenCalled(); + }); + + it('returns 404 when the trigger is declared but disabled — distinct decision reason', async () => { + mockGetResolvedTriggerConfig.mockResolvedValue({ enabled: false, parameters: {} }); + const res = await post(buildApp(), { headers: bearer('correct-password') }); + expect(res.status).toBe(404); + + const reasons = mockLogWebhookCall.mock.calls.map( + (call) => (call[0] as { decisionReason?: string }).decisionReason, + ); + expect(reasons.some((r) => r?.includes('disabled'))).toBe(true); + }); + }); + + describe('body handling', () => { + it('rejects an oversized body with 413 without dispatching', async () => { + const res = await post(buildApp(), { + headers: bearer('correct-password'), + body: 'x'.repeat(EXTERNAL_WEBHOOK_BODY_MAX_BYTES + 1), + }); + expect(res.status).toBe(413); + expect(mockCreateQueuedRun).not.toHaveBeenCalled(); + }); + + it('passes undefined triggerCommentBody for an empty body', async () => { + const res = await post(buildApp(), { headers: bearer('correct-password'), body: '' }); + expect(res.status).toBe(200); + expect(mockSubmitDashboardJob).toHaveBeenCalledWith( + expect.objectContaining({ triggerCommentBody: undefined }), + ); + }); + }); + + describe('dispatch', () => { + it('queues a run and returns 200 with the runId', async () => { + const res = await post(buildApp(), { headers: bearer('correct-password') }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, runId: 'run-123' }); + + expect(mockCreateQueuedRun).toHaveBeenCalledWith({ + projectId: 'proj-1', + agentType: 'implementation', + engine: 'claude-code', + triggerType: 'external-webhook', + }); + expect(mockSubmitDashboardJob).toHaveBeenCalledWith({ + type: 'manual-run', + projectId: 'proj-1', + agentType: 'implementation', + triggerCommentBody: '{"message":"do the thing"}', + triggerType: 'external-webhook', + triggerEvent: 'internal:external-webhook', + runId: 'run-123', + }); + }); + + it('fails the pre-created run and returns a generic 500 when enqueue throws', async () => { + mockSubmitDashboardJob.mockRejectedValue(new Error('redis down at 10.0.0.5:6379')); + + const res = await post(buildApp(), { headers: bearer('correct-password') }); + + expect(res.status).toBe(500); + // Internal detail stays out of the response body + expect(JSON.stringify(await res.json())).not.toContain('redis'); + expect(mockFailQueuedOrRunningRun).toHaveBeenCalledWith( + 'run-123', + 'Failed to enqueue external webhook run', + ); + }); + + it('logs a processed webhook row with the body on success', async () => { + await post(buildApp(), { headers: bearer('correct-password') }); + + const successLog = mockLogWebhookCall.mock.calls + .map((call) => call[0] as Record) + .find((input) => input.statusCode === 200); + expect(successLog).toMatchObject({ + source: 'external', + processed: true, + projectId: 'proj-1', + eventType: 'internal:external-webhook', + bodyRaw: '{"message":"do the thing"}', + }); + }); + }); +}); diff --git a/tests/unit/router/worker-env.test.ts b/tests/unit/router/worker-env.test.ts index 15f98dd60..eba5d015e 100644 --- a/tests/unit/router/worker-env.test.ts +++ b/tests/unit/router/worker-env.test.ts @@ -163,6 +163,20 @@ describe('buildWorkerEnv', () => { expect(env).toContain('CASCADE_CREDENTIAL_KEYS=GITHUB_TOKEN'); }); + it('excludes EXTERNAL_WEBHOOK_PASSWORD_* keys from worker env (inbound-auth verifiers)', async () => { + mockGetAllProjectCredentials.mockResolvedValue({ + GITHUB_TOKEN: 'ghp_test', + EXTERNAL_WEBHOOK_PASSWORD_IMPLEMENTATION: 'super-secret-webhook-pw', + }); + + const env = await buildWorkerEnv(makeJob() as never); + + expect(env.some((e) => e.includes('EXTERNAL_WEBHOOK_PASSWORD'))).toBe(false); + expect(env.some((e) => e.includes('super-secret-webhook-pw'))).toBe(false); + expect(env).toContain('GITHUB_TOKEN=ghp_test'); + expect(env).toContain('CASCADE_CREDENTIAL_KEYS=GITHUB_TOKEN'); + }); + it('skips credential env vars if credential resolution fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); mockGetAllProjectCredentials.mockRejectedValue(new Error('DB error')); diff --git a/tests/unit/triggers/external-webhook-helper.test.ts b/tests/unit/triggers/external-webhook-helper.test.ts new file mode 100644 index 000000000..5706e15b7 --- /dev/null +++ b/tests/unit/triggers/external-webhook-helper.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { TRIGGER_EVENTS } from '../../../src/triggers/shared/events.js'; +import { + EXTERNAL_WEBHOOK_EVENT, + externalWebhookCredentialKey, + externalWebhookPath, + isValidAgentTypeSlug, +} from '../../../src/triggers/shared/external-webhook.js'; + +const CREDENTIAL_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; + +describe('external webhook helpers', () => { + it('event constant matches the canonical catalog', () => { + expect(EXTERNAL_WEBHOOK_EVENT).toBe(TRIGGER_EVENTS.INTERNAL.EXTERNAL_WEBHOOK); + }); + + describe('externalWebhookCredentialKey', () => { + it('maps simple agent types', () => { + expect(externalWebhookCredentialKey('implementation')).toBe( + 'EXTERNAL_WEBHOOK_PASSWORD_IMPLEMENTATION', + ); + }); + + it('maps kebab-case agent types with underscores', () => { + expect(externalWebhookCredentialKey('backlog-manager')).toBe( + 'EXTERNAL_WEBHOOK_PASSWORD_BACKLOG_MANAGER', + ); + expect(externalWebhookCredentialKey('resolve-conflicts')).toBe( + 'EXTERNAL_WEBHOOK_PASSWORD_RESOLVE_CONFLICTS', + ); + }); + + it('always produces a valid project credential key', () => { + for (const agent of [ + 'implementation', + 'planning', + 'splitting', + 'backlog-manager', + 'review', + 'resolve-conflicts', + 'alerting', + ]) { + expect(externalWebhookCredentialKey(agent)).toMatch(CREDENTIAL_KEY_RE); + } + }); + + it('throws on invalid slugs', () => { + expect(() => externalWebhookCredentialKey('Not-Valid')).toThrow(); + expect(() => externalWebhookCredentialKey('../etc')).toThrow(); + expect(() => externalWebhookCredentialKey('')).toThrow(); + }); + }); + + describe('isValidAgentTypeSlug', () => { + it('accepts lowercase kebab slugs', () => { + expect(isValidAgentTypeSlug('implementation')).toBe(true); + expect(isValidAgentTypeSlug('backlog-manager')).toBe(true); + }); + + it('rejects uppercase, traversal, empty, digit-leading, and overlong values', () => { + expect(isValidAgentTypeSlug('Implementation')).toBe(false); + expect(isValidAgentTypeSlug('../etc/passwd')).toBe(false); + expect(isValidAgentTypeSlug('')).toBe(false); + expect(isValidAgentTypeSlug('1agent')).toBe(false); + expect(isValidAgentTypeSlug('a'.repeat(65))).toBe(false); + }); + }); + + it('builds the router path', () => { + expect(externalWebhookPath('proj-1', 'implementation')).toBe( + '/external/webhook/proj-1/implementation', + ); + }); +}); diff --git a/tests/unit/triggers/shared/events.test.ts b/tests/unit/triggers/shared/events.test.ts index 70130e056..f7a2d7d6c 100644 --- a/tests/unit/triggers/shared/events.test.ts +++ b/tests/unit/triggers/shared/events.test.ts @@ -27,6 +27,7 @@ describe('TRIGGER_EVENTS', () => { 'alerting:metric-alert', 'alerting:issue-lifecycle', 'internal:auto-chain', + 'internal:external-webhook', ]); }); diff --git a/web/src/components/projects/agent-config-detail.tsx b/web/src/components/projects/agent-config-detail.tsx index 9e192bb9b..bb4b9a3d4 100644 --- a/web/src/components/projects/agent-config-detail.tsx +++ b/web/src/components/projects/agent-config-detail.tsx @@ -21,8 +21,10 @@ import { } from '@/components/ui/select.js'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.js'; import { AGENT_LABELS, CATEGORY_LABELS } from '@/lib/trigger-agent-mapping.js'; +import { EXTERNAL_WEBHOOK_EVENT } from '../../../../src/triggers/shared/external-webhook.js'; import type { AgentDetailViewProps, DefinitionAgentSectionProps } from './agent-config-types.js'; import { AgentPromptOverrides } from './agent-prompt-overrides.js'; +import { ExternalWebhookTriggerConfig } from './external-webhook-trigger-config.js'; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: tabbed detail panel managing Engine/Prompts/Triggers tabs with per-tab state, mutations, and trigger category grouping function DefinitionAgentSection({ @@ -350,6 +352,15 @@ function DefinitionAgentSection({ onTriggerParamChange(agentType, event, params, currentTrigger?.enabled ?? true); }} idPrefix={`${agentType}-${category}`} + renderTriggerExtra={(trigger) => + trigger.event === EXTERNAL_WEBHOOK_EVENT ? ( + + ) : null + } /> ); diff --git a/web/src/components/projects/external-webhook-trigger-config.tsx b/web/src/components/projects/external-webhook-trigger-config.tsx new file mode 100644 index 000000000..eb0bd325d --- /dev/null +++ b/web/src/components/projects/external-webhook-trigger-config.tsx @@ -0,0 +1,100 @@ +/** + * Inline configuration block for the external webhook trigger, rendered under + * the trigger row in the agent's Triggers tab when the trigger is enabled. + * Shows the per-project-per-agent webhook URL (copy button + curl example) + * and the password field (a project credential — org inheritance applies). + */ + +import { useQuery } from '@tanstack/react-query'; +import { AlertTriangle } from 'lucide-react'; +import { ProjectSecretField } from '@/components/projects/project-secret-field.js'; +import { CopyButton } from '@/components/ui/copy-button.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc } from '@/lib/trpc.js'; +import { + externalWebhookCredentialKey, + externalWebhookPath, +} from '../../../../src/triggers/shared/external-webhook.js'; + +export function ExternalWebhookTriggerConfig({ + projectId, + agentType, + enabled, +}: { + projectId: string; + agentType: string; + enabled: boolean; +}) { + const publicUrlQuery = useQuery({ + ...trpc.system.getPublicUrl.queryOptions(), + enabled, + }); + const credentialsQuery = useQuery({ + ...trpc.projects.credentials.list.queryOptions({ projectId }), + enabled, + }); + + if (!enabled) return null; + + // The URL must point at the ROUTER service, so API_URL (the dashboard API) + // is deliberately not a fallback here. WEBHOOK_CALLBACK_BASE_URL is the + // authoritative source; the dev-server origin swap covers local dev only. + const devOrigin = + typeof window !== 'undefined' && window.location.origin.includes(':5173') + ? window.location.origin.replace(':5173', ':3000') + : ''; + const callbackBaseUrl = publicUrlQuery.data?.routerPublicUrl ?? devOrigin; + const webhookUrl = `${callbackBaseUrl || ''}${externalWebhookPath(projectId, agentType)}`; + + const credentialKey = externalWebhookCredentialKey(agentType); + const credential = credentialsQuery.data?.find((c) => c.envVarKey === credentialKey); + + const curlExample = [ + `curl -X POST '${webhookUrl}' \\`, + ` -H 'Authorization: Bearer ' \\`, + ` -H 'Content-Type: application/json' \\`, + ` -d '{"message": "Describe what the agent should do"}'`, + ].join('\n'); + + return ( +
+
+ +

+ POST to this URL to dispatch the {agentType} agent. The request body reaches the agent as + trigger context. +

+
+ {webhookUrl} + +
+
+ +
+ + Example request + +
+
{curlExample}
+ +
+
+ + {!credential?.isConfigured && ( +
+ + Requests are rejected until a password is set. +
+ )} + + +
+ ); +} diff --git a/web/src/components/shared/definition-trigger-toggles.tsx b/web/src/components/shared/definition-trigger-toggles.tsx index 0908259bf..7d38539bb 100644 --- a/web/src/components/shared/definition-trigger-toggles.tsx +++ b/web/src/components/shared/definition-trigger-toggles.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge.js'; import { TriggerParameterInput } from './trigger-parameter-input.js'; @@ -15,6 +16,8 @@ interface Props { onParamChange: (event: string, parameters: Record) => void; idPrefix?: string; disabled?: boolean; + /** Optional extra UI rendered below a trigger's params block (e.g. webhook URL + password). */ + renderTriggerExtra?: (trigger: ResolvedTrigger) => ReactNode; } /** @@ -27,6 +30,7 @@ export function DefinitionTriggerToggles({ onParamChange, idPrefix, disabled, + renderTriggerExtra, }: Props) { if (triggers.length === 0) return null; @@ -86,6 +90,8 @@ export function DefinitionTriggerToggles({ ))} )} + + {renderTriggerExtra?.(trigger)} ); })}