Skip to content

feat(webhooks): adcp.webhooks.deliver() + A2A artifacts conformance#213

Merged
bokelley merged 2 commits intomainfrom
bokelley/a2a-artifacts-webhooks-deliver
Apr 20, 2026
Merged

feat(webhooks): adcp.webhooks.deliver() + A2A artifacts conformance#213
bokelley merged 2 commits intomainfrom
bokelley/a2a-artifacts-webhooks-deliver

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented Apr 20, 2026

Summary

Two issues closed after three expert-review rounds (code / protocol / DX / security).

What deliver() does

  • Reads url, optional token, optional authentication.{schemes, credentials} from a PushNotificationConfig / ReportingWebhook / equivalent dict.
  • BearerAuthorization: Bearer …. HMAC-SHA256X-AdCP-Signature + X-AdCP-Timestamp over the exact POSTed bytes.
  • Missing / unknown authentication raises pointing at WebhookSender (the AdCP 4.0 default); one-shot DeprecationWarning on first legacy-auth use.
  • Token-echo is opt-in via token_field= — the spec says "echoed in the payload" but doesn't name the field, so the caller picks one the receiver reads.

Defense-in-depth

  • HTTPS-only URL; userinfo rejection (https://user:pass@host/ gets logged by every intermediary).
  • CRLF / NUL rejection on credentials + extra_headers values.
  • Reserved-header blocklist (Authorization, Content-, Host, Signature, Signature-Input, X-AdCP-); per-class error messages name the fix.
  • 10MB body-size cap in both deliver() and WebhookSender.send_raw (parity).
  • 64-entry extra_headers cap.
  • authentication must be a Mapping; schemes must be a list.

Docs

skills/build-seller-agent/SKILL.md gets a new "Emitting Webhooks" section showing the 4.0 default (WebhookSender) and the 3.x legacy (deliver) paths side-by-side, plus production notes (shared httpx.AsyncClient, egress transport, token_field coordination).

Follow-ups

  • Filed upstream: adcontextprotocol/adcp#2503 — spec precedence when both a 9421 key and legacy authentication are present on a PushNotificationConfig. The deliver() / WebhookSender split in this PR doesn't need a decision first; unifying the two into a precedence-aware helper later does.
  • Not filed yet: IP-pinned httpx.AsyncTransport factory for SSRF mitigation (~40 LOC + TOCTOU handling). The helper documents this as caller responsibility today.

Test plan

Expert-review rounds (summary)

  • Round 1 surfaced 10 blockers (spec-invented wire field, spec-violating unsigned default, missing DeprecationWarning, SSRF, CRLF, incomplete reserved-header set, missing triage docstring, non-actionable error messages, ignored timeout_seconds, missing config-error tests). All addressed.
  • Round 2 surfaced 5 new issues (reserved-header regression, userinfo in URL, body-size cap, extra_headers count cap, authentication type-check). All addressed.
  • Round 3: four-way ship.

🤖 Generated with Claude Code

bokelley and others added 2 commits April 19, 2026 23:35
Closes #211. The SDK's A2A server already emits result.artifacts correctly
across the success, list_scenarios, ControllerError, and unknown-scenario
paths — this test drives the full A2A Starlette app through an ASGI
transport and asserts the JSON-RPC wire shape so any future regression
fires here before it surfaces in external storyboard validators.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #212. Collapses the seller's six-step boilerplate (build envelope,
serialize, sign, merge headers, POST, echo token) into one call so the
signer and the wire see the *same bytes* — the serialization-format drift
PR #205 fixed in the hand-rolled path is structurally impossible here.

Covers the legacy AdCP 3.x authentication schemes (Bearer, HMAC-SHA256)
and emits a one-shot DeprecationWarning pointing migrators at WebhookSender
for RFC 9421. Missing or unknown authentication raises with a message that
names the fix (use WebhookSender), not a silent unsigned POST.

Token-echo is opt-in via ``token_field=`` — the AdCP spec says the token
is "echoed back in the payload" but doesn't name the field, so the caller
picks one the receiver agrees to read.

Defense-in-depth at the helper boundary:
  * HTTPS-only URL; rejects embedded userinfo (getting logged by every
    HTTP intermediary is a footgun).
  * CRLF / NUL rejection on credentials + extra_headers (belt-and-braces
    over httpx's own header validation).
  * Reserved-header blocklist covers Authorization, Content-*, Host,
    Signature, Signature-Input, X-AdCP-*; each class gets a fix-hint
    tailored to the likely mistake.
  * 10MB body-size cap (shared with WebhookSender.send_raw for parity).
  * 64-entry extra_headers cap.
  * authentication must be a Mapping; schemes must be a list.

Tests (22 for deliver + 1 for WebhookSender parity) cover: Bearer/HMAC
happy paths, byte-identical signing-vs-wire invariant, retry byte-identity,
token-echo opt-in shape (MCP top-level vs Task metadata), default-off
echo, deprecation warning, and every boundary-validation failure mode.

SKILL.md "Emitting Webhooks" section shows both the 4.0 default
(WebhookSender) and the 3.x legacy (deliver) paths side-by-side with
production notes (shared httpx.AsyncClient, egress transport, token_field
coordination).

Four expert reviews (code, protocol, DX, security) across three rounds.
Deferred as follow-up: IP-pinned egress transport factory; upstream AdCP
issue for 9421-vs-legacy precedence when both are on one config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 493f219 into main Apr 20, 2026
9 checks passed
@bokelley bokelley deleted the bokelley/a2a-artifacts-webhooks-deliver branch April 20, 2026 03:42
bokelley added a commit that referenced this pull request Apr 20, 2026
…re form

After rebasing onto main (which merged PR #213 adding deliver()), our
compact-separator fix for get_adcp_signed_headers_for_webhook left
deliver() self-inconsistent: it signed compact bytes via the signer
but POSTed spaced bytes from an inline json.dumps(body_dict).

The test_hmac_auth_signs_posted_bytes invariant on main explicitly
verifies that HMAC(posted_bytes) matches the X-AdCP-Signature header —
so the inconsistency failed CI.

Fix
---
- deliver() now serializes body_bytes with separators=(",", ":"), so
  signer input and transport bytes are byte-identical again.
- test_signed_bytes_match_posted_bytes updated to expect compact bytes
  (was asserting against json.dumps(payload) default — main's test
  pre-dated adcp#2478's canonical-form pin).
- Restore MemoryBackend + WebhookDedupStore re-exports from
  adcp.webhooks, dropped during the rebase.
- Splice sign_legacy_webhook + _compute_legacy_signature back in
  alongside main's new deliver() — both pathways now share the same
  compact-separator serialization core.

Verification: ruff + mypy clean, pytest 1798 passed / 17 skipped.
All main-added tests (A2A comply_test_controller artifacts,
test_webhooks_deliver full suite) pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant