feat: replace the parse-only source registry with a provider descriptor and verifier registry - #378
Draft
VascoSch92 wants to merge 1 commit into
Draft
Conversation
…or and verifier registry A provider was described in three places: BUILTIN_SOURCES mapped a slug to a secret extractor, the event_schemas _PARSERS registry mapped the same slug to a parser, and RESERVED_SOURCES listed the same slugs by hand. Adding one meant editing all three. Verification was not a registry at all: verify_signature() was hard-wired to hex HMAC-SHA256 over the raw body, and a custom webhook could configure only the header name. Standard Webhooks and Slack could not be onboarded. Add openhands/automation/providers.py holding both registries. Provider carries the parser, verifier name, signature header, secret accessor, capabilities, and the subject/handshake hooks reserved for later phases. VERIFIERS resolves a scheme by name: hmac_sha256_hex (unchanged), standard_webhooks (from PR #247) and slack_v0, the last two rejecting deliveries outside a 5-minute window. event_router resolves a verifier instead of calling verify_signature directly, parse_event reads the descriptor, and RESERVED_SOURCES is derived. Add custom_webhooks.signature_scheme as a nullable column; NULL reads as hmac_sha256_hex, the behaviour the row was created with. slack is deliberately not registered as a provider: the OSS VM delivers to that source through a CustomWebhook row, and reserving the name would break it. Slack becomes a provider in #360. Also fix two order-dependent tests in test_router.py that asserted on a browser-supplied telemetry distinct id without forcing local mode; they passed only while an earlier test file left a local-mode config in the cache.
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #359 — phase 2 of 5, part of #363.
Stacked on #367 (phase 1). Base branch is
vasco/accept-event-seam; reviewonly the commits on top of it. Independent of phase 1 in substance — the two
issues had no blocking relationship — but they touch adjacent code, so this is
stacked rather than opened against
main.One nullable column. No behaviour change for any existing source or any
existing row.
What changed
A provider was described in three places:
BUILTIN_SOURCES(utils/webhook.py)mapped a slug to a secret extractor, the
event_schemas_PARSERSregistrymapped the same slug to a parser, and
RESERVED_SOURCES(schemas.py) listedthe same three slugs by hand so a custom webhook could not impersonate one.
Adding a provider meant editing all three and remembering the third.
Verification was not a registry at all.
verify_signature()was hard-wired tohex HMAC-SHA256 over the raw body;
CustomWebhooklet an operator configure theheader name and nothing else. Anything signing something other than the raw
body — Standard Webhooks, Slack — could not be onboarded.
New
openhands/automation/providers.pyholds both registries:event_router.pyresolvesVERIFIERS[config.signature_scheme]instead ofcalling
verify_signature().parse_event()reads the descriptor, so theparser registry is gone.
RESERVED_SOURCESis derived.BUILTIN_SOURCES,register_builtin_source()andregister_parser()are removed;verify_signatureand
is_builtin_sourceare re-exported fromutils/webhook.pyso existingimports keep working.
custom_webhooks.signature_schemeis added nullable (migration017), and aNULLis read ashmac_sha256_hex— the behaviour the row was created with.Four decisions worth reviewing
1. The verifier protocol needed a fifth parameter.
The issue specifies:
That cannot express the scheme it is meant to replace.
hmac_sha256_hexreadsits signature from a header whose name is per-row (
signature_header), so averifier given only
headershas nothing to look the signature up by. Theimplemented signature adds
signature_header: str.The split is deliberate and I think it is the right line: the header carrying
the signature is operator-configurable, so it is a parameter; the headers a
scheme needs besides the signature (
webhook-id,webhook-timestamp,X-Slack-Request-Timestamp) are fixed by the scheme, so verifiers read thosefrom
headersthemselves and no caller has to know about them.2.
slackis deliberately NOT registered as a provider — onlyslack_v0isadded as a verifier.
This one is a live-traffic hazard, so flagging it explicitly. The OSS automation
VM has been posting to
/v1/events/{org_id}/slacksince 2026-08-13 (#363, "Whynow"), and
slackthere is an ordinaryCustomWebhookrow with a per-orgsecret. Registering a
slackprovider would makeget_webhook_config()treatthat source as built-in, read
AUTOMATION_WEBHOOK_SECRETinstead of the row'ssecret, and reserve the name — breaking the bridge and both automations
consuming it on the next deploy. Slack becomes a provider in #360, where the
cutover is designed. Here it only gains a verifier a custom webhook can select.
3. The handshake hook is deferred, and the field is reserved.
The issue asks for this to be decided rather than built on autopilot. Decision:
defer. The motivating example was Slack's Events API challenge, and #360
makes Socket Mode our Slack path, where it does not apply. Nothing else on the
roadmap — GitLab (#66), Stripe, Standard Webhooks, the two existing forwarded
sources — performs an HTTP handshake. The field stays on the descriptor, typed
under
TYPE_CHECKINGsoproviders.pykeeps no runtime FastAPI import, withthe reasoning in a comment next to it. A test asserts no registered provider
sets it, so the deferral stays true rather than quietly decaying.
4.
Provider.signature_headeris a field the issue did not list.get_webhook_config()hard-coded"X-Hub-Signature-256"for every built-insource, with the comment
# GitHub's header. Deriving that from the descriptoris the difference between "adding a provider is a registry entry" and "adding a
provider is a registry entry unless it signs into a different header". Default
is the existing constant, so all three built-ins are byte-identical.
An unrelated pre-existing test bug this surfaced
tests/test_router.py::test_create_automation_successand::test_dispatch_automation_successassert on a browser-supplied telemetrydistinct id, which
telemetry._trusted_telemetry_context()discards outsidelocal mode. They never set local mode. They pass today only because
tests/test_local_mode.pyruns earlier and leaves a local-modeSettingsinthe
lru_cache—monkeypatchreverts the env var but not the cached object.Confirmed pre-existing, not caused by this branch: on
vasco/accept-event-seamwith no changes,
pytest tests/test_router.pyalone fails both.pytest tests/test_local_mode.py tests/test_router.pypasses.tests/test_providers.pysorts betweentest_preset_router.pyandtest_router.pyand clears the config cache around each of its tests, whichremoves the leak and turns the latent failure into a real one. Fixed at the
source with a
local_modefixture on those two tests, so they now pass standalonetoo. This is the only change here to a test file unrelated to the issue; I would
rather fix the dependency than leave the suite order-fragile.
Acceptance criteria
github,jira_dc,bitbucket_data_centerbehave identically —existing tests pass unmodified.
test_event_router.py,test_event_schemas.py,test_webhook_utils.pyandtest_webhook_router.pyare untouched and green, includingtest_webhook_router.py:215'sRESERVED_SOURCES == {...}assertionagainst the now-derived value.
CustomWebhookrows verify exactly as before. Tested threeways:
get_webhook_config()on aNULLcolumn, a full HTTP deliveryagainst a
NULL-column row, and the migration itself — a row inserted atrevision
016, before the column existed, readshmac_sha256_hexafterupgrade headon real Postgres.standard_webhooksand verifiesa known-good fixture, including timestamp rejection outside the replay
window. End-to-end through
POST /v1/events/{org}/{source}: a validdelivery matches an automation; a correctly signed one stamped 10,000s ago
is refused 401. Unit tests additionally cover that the timestamp and the
message id are signed (re-stamping a captured delivery fails), key
derivation from
whsec_, multi-signature rotation, and version tags.changes to
event_router.py.test_adding_a_provider_takes_a_registry_entry_and_nothing_elseregistersa source the service has never heard of, with its own parser, a
non-default verifier, its own signature header and a secret accessor, and
routes a real HTTP delivery end to end. Nothing outside the descriptor was
added to make it pass.
RESERVED_SOURCESis derived, not hand-maintained.reserved_sources()returns
frozenset(PROVIDERS). Validation callsis_builtin_source()rather than the snapshot, so a provider registered after import time is
also protected — tested.
One behaviour change to be aware of
A
signature_schemenaming no registered verifier now returns 500, not asilent fallback to the default. Only reachable by editing the row directly,
since the API validates the scheme on write. Falling back would reject every
genuine delivery as a bad signature and present a misconfiguration as an
authentication failure.
API surface
signature_schemeis added toCustomWebhookCreate(optional, defaults tohmac_sha256_hex),CustomWebhookUpdate(optional), andCustomWebhookResponse/CustomWebhookCreateResponse(required). Verifiedagainst the generated OpenAPI spec: those four schemas and no others.
EventResponseis unchanged, so phase 1's wire guarantee still holds.Verification
test modified except the two order-dependent ones described above.
pre-commit(ruff format, ruff lint, pycodestyle, pyright) clean on alleleven files.
017applied and rolled back on SQLite (upgrade head→downgrade -1, column added nullable with server default, dropped cleanly)and on real Postgres 15, with a pre-existing row present across both
directions.
Out of scope
Stream transports (#360), any use of
subject(#362), and GitLab itself (#66) —this phase only widens the description of a provider.