Skip to content

BlueBubbles webhook route invisible to gateway handler (registry object mismatch) #45598

@dubthree

Description

@dubthree

Summary

Channel plugins that register webhook routes via registerPluginHttpRoute() (called from registerWebhookTargetWithPluginRoute()) add routes to the global singleton plugin registry, but the gateway's HTTP request handler holds a reference to a different registry object. The result: webhook POST requests return 404 even though the route is registered and the logs say it's listening.

This affects BlueBubbles (and likely any channel plugin using registerWebhookTargetWithPluginRoute). Plugins that register via api.registerHttpRoute() during plugin loading (e.g., twilio-whatsapp) are unaffected because they write to the correct registry.

Version

OpenClaw 2026.3.12 (Homebrew, macOS, Node.js 25.5.0)

Steps to Reproduce

  1. Configure BlueBubbles channel in openclaw.json with a webhook path
  2. Start the gateway
  3. POST to the webhook path (e.g., curl -X POST http://localhost:18789/bluebubbles-webhook)
  4. Observe 404 "Not Found"

Root Cause

In createGatewayPluginRequestHandler() (gateway-cli chunk), the registry parameter is captured by closure:

function createGatewayPluginRequestHandler(params) {
    const { registry, log } = params;
    return async (req, res, ...) => {
        // registry is the object from loadOpenClawPlugins()
        const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext);
    };
}

Later, when a channel provider starts, registerPluginHttpRoute() is called without a registry parameter, so it falls back to requireActivePluginRegistry():

function registerPluginHttpRoute(params) {
    const registry = params.registry ?? requireActivePluginRegistry();
    // ...
    routes.push(entry);
}

requireActivePluginRegistry() returns globalThis[Symbol.for("openclaw.pluginRegistryState")].registry, which is a different object than the one captured by the handler closure. Confirmed with instrumentation:

[bb-fix] handler created: same=false capturedRoutes=1
[bb-fix] req: path=/bluebubbles-webhook live=2 captured=1 same=false

The captured registry has 1 route (twilio-whatsapp, registered during plugin loading). The global registry has 2 routes (twilio-whatsapp + bluebubbles). The handler only checks the captured one.

Workaround

Patch createGatewayPluginRequestHandler in the gateway-cli chunk to read from the live global registry on each request:

function createGatewayPluginRequestHandler(params) {
    const { registry, log } = params;
    const _RSYM = Symbol.for("openclaw.pluginRegistryState");
    const _getGlobalRegistry = () => globalThis[_RSYM]?.registry ?? registry;
    return async (req, res, providedPathContext, dispatchContext) => {
        const _liveRegistry = _getGlobalRegistry();
        if ((_liveRegistry.httpRoutes ?? []).length === 0) return false;
        // ... use _liveRegistry instead of registry ...
    };
}

Also requires NODE_DISABLE_COMPILE_CACHE=1 in the launchd plist environment to prevent Node.js compile cache from serving stale bytecode.

Additional Context

  • openclaw doctor reports BlueBubbles as healthy
  • Outbound iMessages work fine
  • The health monitor detects "stale-socket" every ~35 minutes (because no inbound webhooks ever arrive) and restarts the provider, which triggers a register/unregister/register cycle but never fixes the underlying registry mismatch
  • The shouldEnforceGatewayAuthForPluginPath function at the call site also uses the stale captured registry, though this doesn't directly cause the 404 since BlueBubbles uses auth: "plugin"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions