Skip to content

feat: add generic bidirectional webhook channel#626

Open
vrknetha wants to merge 2 commits intosipeed:mainfrom
vrknetha:feat/clawdentity-webhook-channel
Open

feat: add generic bidirectional webhook channel#626
vrknetha wants to merge 2 commits intosipeed:mainfrom
vrknetha:feat/clawdentity-webhook-channel

Conversation

@vrknetha
Copy link

@vrknetha vrknetha commented Feb 22, 2026

Summary

This PR adds a generic bidirectional webhook channel for PicoClaw.

The channel runs one local HTTP server with two routes:

  • POST /v1/inbound for inbound relay deliveries -> PicoClaw inbound message bus.
  • POST /v1/outbound for outbound send requests -> forwarded to local connector.

Default connector forward target:

  • http://127.0.0.1:19400/v1/outbound

What is Clawdentity?

Clawdentity is an open identity and messaging protocol for AI agents.

In this PR, Clawdentity is one consumer of the generic webhook channel. The same channel design also supports:

  • home automation webhooks
  • CI/CD notifications
  • inter-agent messaging bridges
  • internal event fan-in pipelines

What Changed

  • Added/updated WebhookChannel (pkg/channels/webhook.go) as bidirectional:
    • inbound handling (/v1/inbound)
    • outbound forwarding (/v1/outbound)
    • Send() now forwards outbound bus messages to connector
  • Expanded channels.webhook config (pkg/config/config.go):
    • webhook_path
    • send_path
    • connector_url
    • connector_timeout
    • existing token/allowlist settings
  • Updated defaults and example config (pkg/config/defaults.go, config/config.example.json)
  • Updated tests for inbound/outbound behavior (pkg/channels/webhook_test.go, pkg/config/config_test.go)

Request Validation / Auth

  • Both routes require:
    • POST
    • Content-Type: application/json
    • valid JSON body
  • Optional auth (when channels.webhook.token is set):
    • x-webhook-token: <token>
    • Authorization: Bearer <token>

Payload Contracts

  • Inbound (/v1/inbound): accepts any JSON payload.
  • Outbound (/v1/outbound): expects JSON:
    • to (required)
    • content (required)
    • peer (optional)

Security

  • Binds to 127.0.0.1 by default (loopback only)
  • Optional shared secret token auth
  • Optional sender allowlist via allow_from

Validation

  • go test ./pkg/channels ./pkg/config (passing)

@vrknetha vrknetha force-pushed the feat/clawdentity-webhook-channel branch from c5140cf to d99cfc1 Compare February 22, 2026 09:14
@vrknetha vrknetha changed the title feat: add generic webhook inbound channel feat: add generic bidirectional webhook channel Feb 22, 2026
@alexhoshina
Copy link
Collaborator

hi @vrknetha, we are currently undergoing a refactoring of the channel system. I have already opened the refactoring branch, which implements a generic WebSocket channel. Would you be interested in implementing your webhook channel within the new architecture as well?

Copy link

@nikolasdehor nikolasdehor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough implementation with good test coverage (536 lines of tests for 426 lines of code). The security defaults are sensible (loopback-only binding, optional token auth, allowlist). A few observations:

Security concern — token comparison is not constant-time:
In validateJSONRequest, the token comparison token != c.config.Token uses standard string comparison, which is vulnerable to timing attacks. Since this is an auth token check, use subtle.ConstantTimeCompare:

import "crypto/subtle"

if subtle.ConstantTimeCompare([]byte(token), []byte(c.config.Token)) != 1 {
    http.Error(w, "Invalid token", http.StatusForbidden)
    return false
}

Missing auth on outbound route:
The handleOutbound route goes through validateJSONRequest which checks the token, but there is no IsAllowed check for the outbound sender. If the webhook is exposed beyond loopback (user sets webhook_host to 0.0.0.0), anyone could forward messages through the connector. Consider either:

  • Also checking IsAllowed on the outbound route, or
  • Documenting that outbound auth relies solely on the shared token.

Body read after close:
In handleInbound, defer r.Body.Close() is placed after io.ReadAll. The defer will still execute, but the ordering is misleading — the read has already completed. Move the defer before the read call for clarity and consistency (other channels in this repo follow that pattern).

setRunning(true) before goroutine confirms server is listening:
In Start(), setRunning(true) is called before the go func() that calls server.Serve(). If Serve fails immediately (e.g., TLS config error), the channel reports as running even though it is not. This is a minor race since most failures are caught by net.Listen above, but worth noting.

Outbound route re-forwards to connector — clarify purpose:
The /v1/outbound HTTP handler receives a payload and then forwards it to connectorURL. This means an external caller POSTs to the local server, which then re-POSTs to the connector. The PR description says this is for "outbound send requests -> forwarded to local connector," but it is not immediately obvious why the extra hop is needed vs. the caller posting directly to the connector. A code comment explaining the relay architecture would help future maintainers.

Overall this is well-structured and the test coverage is solid. The constant-time token comparison is the most important fix.

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.

3 participants