diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index d70f3802a6c..a8f50daa868 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -251,6 +251,26 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(f.wakeups).toEqual([]); }); + it("lets only a person choose an unauthenticated public webhook trigger", async () => { + const f = await seedFixture(); + const webhook = { kind: "webhook", enabled: true, replayWindowSec: 300 } as const; + const agent = { agentId: f.agentId }; + await expect(f.svc.createTrigger(f.routine.id, { ...webhook, signingMode: "none" }, agent)) + .rejects.toMatchObject({ status: 403, message: expect.stringContaining("Agents can only create authenticated webhook triggers") }); + const signed = await f.svc.createTrigger(f.routine.id, { ...webhook, signingMode: "bearer" }, agent); + expect(signed.trigger.signingMode).toBe("bearer"); + await expect(f.svc.updateTrigger(signed.trigger.id, { signingMode: "none" }, agent)).rejects.toMatchObject({ status: 403 }); + const open = await f.svc.createTrigger(f.routine.id, { ...webhook, signingMode: "none" }, { userId: "board-user" }); + expect(open.trigger.signingMode).toBe("none"); + await expect(f.svc.updateTrigger(open.trigger.id, { label: "Inbound", signingMode: "none" }, agent)) + .resolves.toMatchObject({ trigger: { label: "Inbound", signingMode: "none" } }); + await f.svc.updateTrigger(signed.trigger.id, { signingMode: "none" }, { userId: "board-user" }); + const unsafeRevision = (await f.svc.listRevisions(f.routine.id))[0]!.id; + await f.svc.updateTrigger(signed.trigger.id, { signingMode: "bearer" }, { userId: "board-user" }); + await expect(f.svc.restoreRevision(f.routine.id, unsafeRevision, agent)).rejects.toMatchObject({ status: 403 }); + expect((await f.svc.getTrigger(signed.trigger.id))!.signingMode).toBe("bearer"); + }); + it("preserves creation ancestry through regular webhooks, child tasks, and standalone follow-ups", async () => { vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID().repeat(2)); const f = await seedFixture(); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 39c6b24dac6..e12ed9432c8 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -163,6 +163,13 @@ type Actor = { actorResponsibleUserId?: string | null; runId?: string | null; }; + +export const AGENT_UNAUTHENTICATED_WEBHOOK_ERROR = + "Agents can only create authenticated webhook triggers. Use signingMode \"bearer\" (the default) or a signed mode such as \"hmac_sha256\". If the sender cannot authenticate its requests, ask a person to choose an unauthenticated webhook on the board."; + +function assertAgentWebhookAuthenticated(actor: Actor, signingMode: string | null | undefined) { + if (actor.agentId && signingMode === "none") throw forbidden(AGENT_UNAUTHENTICATED_WEBHOOK_ERROR); +} type RoutineRow = typeof routines.$inferSelect; type RoutineTriggerRow = typeof routineTriggers.$inferSelect; @@ -2537,6 +2544,7 @@ export function routineService( } if (input.kind === "webhook") { + assertAgentWebhookAuthenticated(actor, input.signingMode); publicId = crypto.randomBytes(12).toString("hex"); const created = await createWebhookSecret(routine.companyId, routine.id, actor); secretId = created.secret.id; @@ -2623,8 +2631,17 @@ export function routineService( const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; - await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); + const current = await txDb + .select({ signingMode: routineTriggers.signingMode }) + .from(routineTriggers) + .where(eq(routineTriggers.id, id)) + .then((rows) => rows[0] ?? null); + if (!current) return null; + if (existing.kind === "webhook" && current.signingMode !== "none") { + assertAgentWebhookAuthenticated(actor, patch.signingMode); + } + await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); const [updated] = await txDb .update(routineTriggers) .set({ @@ -2635,7 +2652,7 @@ export function routineService( cronExpression, timezone, nextRunAt, - signingMode: patch.signingMode === undefined ? existing.signingMode : patch.signingMode, + signingMode: patch.signingMode === undefined ? current.signingMode : patch.signingMode, replayWindowSec: patch.replayWindowSec === undefined ? existing.replayWindowSec : patch.replayWindowSec, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, @@ -2779,10 +2796,8 @@ export function routineService( const snapshot = routineRevisionSnapshotSchema.parse(targetRevision.snapshot) as RoutineRevisionSnapshotV1; const routineSnapshot = snapshot.routine; await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); - const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; - await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`); const locked = await txDb .select() @@ -2797,9 +2812,16 @@ export function routineService( } const currentTriggers = await txDb - .select({ id: routineTriggers.id }) + .select({ id: routineTriggers.id, signingMode: routineTriggers.signingMode }) .from(routineTriggers) .where(and(eq(routineTriggers.companyId, locked.companyId), eq(routineTriggers.routineId, locked.id))); + const unauthenticatedTriggerIds = new Set(currentTriggers.filter((trigger) => trigger.signingMode === "none").map((trigger) => trigger.id)); + for (const trigger of snapshot.triggers) { + if (trigger.kind === "webhook" && !unauthenticatedTriggerIds.has(trigger.id)) { + assertAgentWebhookAuthenticated(actor, trigger.signingMode); + } + } + await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); const currentTriggerIds = new Set(currentTriggers.map((trigger) => trigger.id)); const missingWebhookTriggers = snapshot.triggers .filter((trigger) => trigger.kind === "webhook" && !currentTriggerIds.has(trigger.id)); diff --git a/skills/paperclip/references/routines.md b/skills/paperclip/references/routines.md index 3378cffb69e..cf48e3d4402 100644 --- a/skills/paperclip/references/routines.md +++ b/skills/paperclip/references/routines.md @@ -157,7 +157,7 @@ POST /api/routines/{routineId}/triggers } ``` -- `signingMode`: `bearer` (default) or `hmac_sha256` +- `signingMode`: `bearer` (default) or `hmac_sha256`. Agents can only create authenticated webhook triggers; if a sender cannot authenticate, ask a person to set that trigger up on the board. - `replayWindowSec`: 30-86400 (default 300) - Response includes the webhook URL (`publicId`-based) and the signing secret - Fire externally: `POST /api/routine-triggers/public/{publicId}/fire`