feat(features,database,service,web): an automation's webhook address queues a run under the trigger's own secret, its body handed to the agent

Merged
DA
4d ago
+9943-32

Summary

Part 1, step 2 of Dean's "GitHub events and webhooks, then the code review bot". This revives the webhook trigger parked as draft #2516, ported onto today's layout (packages/service, packages/features), and supersedes it. It is stacked on #2768.

  • The address. POST /api/automations/{id}/webhook (automation-webhook-app.ts) is called by whatever the developer hands it to (Sentry, CI, a script), with no account bearer.
    • It is admitted by the trigger's own secret, sent as the Authorization: Bearer value only. The secret is hashed and compared in constant time with the SHA-256 the row keeps.
    • It is never read from the address, because a platform's request log keeps the address whole. #2516's token query is dropped, per Devin's review.
    • A wrong secret, an id that names no webhook, and a departed owner's address are all invalid-token. That last case is the reach gate #2564 had moved into #2516.
  • The run.
    • A paused automation answers 200 { queued: 0 } before reading the body, so a sender doesn't retry. Otherwise the call is 202 { queued }, recorded under the SHA-256 of the caller's Idempotency-Key, so a retry is the same run.
    • The body (UTF-8, ≤ 64 KiB, no NUL, since a text column can't keep one) is kept on the new automation_run.payload column, which only the act that starts the run reads, never a history or a guard.
  • The task. The agent is handed the body fenced as <webhook-body>, with its angle brackets escaped and the instruction to read it as data. It is cut with a note where it would push the task past the 16,000-character message bound.
  • The secret.
    • Minted at create (or on a change to a webhook trigger) and shown once as webhookSecret, as today; hashed at rest, as today.
    • Rotatable: PATCH { webhookSecretHash } replaces it with one the caller minted, sent as its SHA-256. The caller holds the new secret before the old one stops admitting calls, and the service never sees it. The editor's host (#2776) mints the secret, copies it, then sends the hash, so a failed copy rotates nothing (Devin's review on #2776). It needs change access, and is invalid-request for any other trigger.
  • AGENTS.md: Dean's approved carve-out (automations D10), worded as approved: "a webhook body an automation's caller sends may reach that run's agent task, transcript and traces".
  • packages/service/README.md documents the address and the rotation.

The editor's dialog, with "don't send secrets in the body", comes in the UI PR of this stack.

Evidence

  • Platform-independent checks: pnpm check:affected passes.
  • Migration: pnpm db:generate --name=automation_run_payload, one nullable text column, so it is additive.
  • New tests, each failing without its change:
    • automation-webhook-app.test.ts (5):
      • bearer calls queue a run with the body, a retry under the same key queues none, and a secret in the address is refused;
      • a wrong secret, an unknown id, no secret, and a replaced secret are refused alike, and a paused automation acknowledges even an oversized body;
      • a body past 64 KiB, a non-UTF-8 body, and a NUL body are refused, and a secret replaced on a non-webhook is refused;
      • a key of any length names one run, and a short key spelling a long key's digest is its own run;
      • a departed owner's address answers as none and records nothing.
    • automation-runs.test.ts: a webhook firing hands the agent the body fenced and escaped (the fence closes once), and a 40k body is cut under the bound.
    • automation-store.test.ts: readWebhookAutomation reads a webhook for a member owner, nothing for another trigger, and nothing for a departed owner; the run keeps the body for queuedAutomationRuns.
  • Test count: +7.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged.
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged.
  • No new as assertion outside the test body's SAFETY:-justified JSON read, the same as the sibling route tests.
  • No run outside the edges, and no raw async primitives.
  • No dropped Effect.
  • Test count delta listed (+7).
  • No hand-rolled file replaced.
  • AGENTS.md is edited with Dean's approved line; CLAUDE.md is its symlink.
  • PRIVACY.md unchanged. The body reaches only the run's agent, under the carve-out.
  • Package deps unchanged: a new subpath export in @luke/service, and the route added with functions:rewrites.
  • Barrel/door rule unaffected.

Notes

  • Supersedes #2516, which will be commented on and closed.
  • Mac: no desktop change. CI could not verify the Mac UI.

šŸ¤– Generated with Claude Code