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+9943+0123456789012345678901234567890123456789-32-32-01234567890123456789
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: Bearervalue 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
tokenquery 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.
- It is admitted by the trigger's own secret, sent as the
- The run.
- A paused automation answers
200 { queued: 0 }before reading the body, so a sender doesn't retry. Otherwise the call is202 { queued }, recorded under the SHA-256 of the caller'sIdempotency-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.payloadcolumn, which only the act that starts the run reads, never a history or a guard.
- A paused automation answers
- 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 isinvalid-requestfor any other trigger.
- Minted at create (or on a change to a webhook trigger) and shown once as
- 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.mddocuments 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:affectedpasses. - Migration:
pnpm db:generate --name=automation_run_payload, one nullabletextcolumn, 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:readWebhookAutomationreads a webhook for a member owner, nothing for another trigger, and nothing for a departed owner; the run keeps the body forqueuedAutomationRuns.
- Test count: +7.
Invariants
-
./scripts/check.shgreen. (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
asassertion outside the test body'sSAFETY:-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.mdunchanged. 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 withfunctions: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