Skip to content

feat: replace the parse-only source registry with a provider descriptor and verifier registry - #378

Draft
VascoSch92 wants to merge 1 commit into
vasco/accept-event-seamfrom
vasco/provider-descriptor-registry
Draft

feat: replace the parse-only source registry with a provider descriptor and verifier registry#378
VascoSch92 wants to merge 1 commit into
vasco/accept-event-seamfrom
vasco/provider-descriptor-registry

Conversation

@VascoSch92

Copy link
Copy Markdown
Member

Closes #359 — phase 2 of 5, part of #363.

Stacked on #367 (phase 1). Base branch is vasco/accept-event-seam; review
only 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 _PARSERS registry
mapped the same slug to a parser, and RESERVED_SOURCES (schemas.py) listed
the 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 to
hex HMAC-SHA256 over the raw body; CustomWebhook let an operator configure the
header name and nothing else. Anything signing something other than the raw
body — Standard Webhooks, Slack — could not be onboarded.

New openhands/automation/providers.py holds both registries:

VERIFIERS: dict[str, WebhookVerifier] = {
    "hmac_sha256_hex":   HmacSha256HexVerifier(),    # unchanged behaviour, incl. bare hex
    "standard_webhooks": StandardWebhooksVerifier(), # from PR #247
    "slack_v0":          SlackV0Verifier(),          # v0:{ts}:{body}, timestamp checked
}

@dataclass(frozen=True, slots=True)
class Provider:
    source: str
    parse: ParseFunc
    verifier: str = DEFAULT_VERIFIER
    signature_header: str = DEFAULT_BUILTIN_SIGNATURE_HEADER
    secret_from_settings: SecretFunc | None = None
    subject: SubjectFunc | None = None          # reserved for #362
    handshake: HandshakeFunc | None = None      # reserved; see below
    capabilities: Capabilities = Capabilities()

event_router.py resolves VERIFIERS[config.signature_scheme] instead of
calling verify_signature(). parse_event() reads the descriptor, so the
parser registry is gone. RESERVED_SOURCES is derived. BUILTIN_SOURCES,
register_builtin_source() and register_parser() are removed; verify_signature
and is_builtin_source are re-exported from utils/webhook.py so existing
imports keep working.

custom_webhooks.signature_scheme is added nullable (migration 017), and a
NULL is read as hmac_sha256_hex — the behaviour the row was created with.

Four decisions worth reviewing

1. The verifier protocol needed a fifth parameter.

The issue specifies:

def verify(self, *, body: bytes, headers: Mapping[str, str], secret: str) -> bool: ...

That cannot express the scheme it is meant to replace. hmac_sha256_hex reads
its signature from a header whose name is per-row (signature_header), so a
verifier given only headers has nothing to look the signature up by. The
implemented 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 those
from headers themselves and no caller has to know about them.

2. slack is deliberately NOT registered as a provider — only slack_v0 is
added 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}/slack since 2026-08-13 (#363, "Why
now"), and slack there is an ordinary CustomWebhook row with a per-org
secret. Registering a slack provider would make get_webhook_config() treat
that source as built-in, read AUTOMATION_WEBHOOK_SECRET instead of the row's
secret, 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_CHECKING so providers.py keeps no runtime FastAPI import, with
the 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_header is a field the issue did not list.

get_webhook_config() hard-coded "X-Hub-Signature-256" for every built-in
source, with the comment # GitHub's header. Deriving that from the descriptor
is 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_success and
::test_dispatch_automation_success assert on a browser-supplied telemetry
distinct id, which telemetry._trusted_telemetry_context() discards outside
local mode. They never set local mode. They pass today only because
tests/test_local_mode.py runs earlier and leaves a local-mode Settings in
the lru_cachemonkeypatch reverts the env var but not the cached object.

Confirmed pre-existing, not caused by this branch: on vasco/accept-event-seam
with no changes, pytest tests/test_router.py alone fails both. pytest tests/test_local_mode.py tests/test_router.py passes.

tests/test_providers.py sorts between test_preset_router.py and
test_router.py and clears the config cache around each of its tests, which
removes the leak and turns the latent failure into a real one. Fixed at the
source with a local_mode fixture on those two tests, so they now pass standalone
too. 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_center behave identically —
    existing tests pass unmodified.
    test_event_router.py,
    test_event_schemas.py, test_webhook_utils.py and
    test_webhook_router.py are untouched and green, including
    test_webhook_router.py:215's RESERVED_SOURCES == {...} assertion
    against the now-derived value.
  • Existing CustomWebhook rows verify exactly as before. Tested three
    ways: get_webhook_config() on a NULL column, a full HTTP delivery
    against a NULL-column row, and the migration itself — a row inserted at
    revision 016, before the column existed, reads hmac_sha256_hex after
    upgrade head on real Postgres.
  • A custom webhook can be configured with standard_webhooks and verifies
    a known-good fixture, including timestamp rejection outside the replay
    window.
    End-to-end through POST /v1/events/{org}/{source}: a valid
    delivery 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.
  • Adding GitLab (PR feat: Add GitLab event support for automation triggers #66) requires one new file and a registry entry — no
    changes to event_router.py.

    test_adding_a_provider_takes_a_registry_entry_and_nothing_else registers
    a 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_SOURCES is derived, not hand-maintained. reserved_sources()
    returns frozenset(PROVIDERS). Validation calls is_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_scheme naming no registered verifier now returns 500, not a
silent 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_scheme is added to CustomWebhookCreate (optional, defaults to
hmac_sha256_hex), CustomWebhookUpdate (optional), and
CustomWebhookResponse / CustomWebhookCreateResponse (required). Verified
against the generated OpenAPI spec: those four schemas and no others.
EventResponse is unchanged, so phase 1's wire guarantee still holds.

Verification

  • Full suite: 1422 passed (1350 on the base branch, +72 new). No existing
    test modified except the two order-dependent ones described above.
  • pre-commit (ruff format, ruff lint, pycodestyle, pyright) clean on all
    eleven files.
  • Migration 017 applied 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.

…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.
@github-actions github-actions Bot added the type: feat A new feature label Aug 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant