Skip to content

fix(plugins): preserve channel webhook routes across registry replacements#47676

Closed
TurboTheTurtle wants to merge 3 commits intoopenclaw:mainfrom
TurboTheTurtle:fix/bluebubbles-webhook-route-orphan
Closed

fix(plugins): preserve channel webhook routes across registry replacements#47676
TurboTheTurtle wants to merge 3 commits intoopenclaw:mainfrom
TurboTheTurtle:fix/bluebubbles-webhook-route-orphan

Conversation

@TurboTheTurtle
Copy link
Copy Markdown

Problem

Channel plugins (e.g. BlueBubbles) register HTTP webhook routes via requireActivePluginRegistry() during channel startup. However, the per-agent plugin loader calls setActivePluginRegistry() with a fresh registry for each agent, orphaning previously registered httpRoutes. The gateway HTTP handler then reads from the latest registry and finds zero routes, returning 404 for all webhook POSTs.

This breaks inbound message delivery for any channel that relies on webhook HTTP routes (BlueBubbles being the primary one).

Root Cause

  1. createGatewayPluginRequestHandler() captures a registry snapshot at creation time
  2. Channel startup (e.g. BlueBubbles monitorBlueBubblesProvider) registers webhook routes into requireActivePluginRegistry()
  3. Subsequent per-agent plugin loader cycles call setActivePluginRegistry() with a new empty registry
  4. The captured snapshot and the current active registry diverge — both lose the webhook routes

Fix

Two changes:

  1. setActivePluginRegistry() now carries forward httpRoutes from the previous registry when the new one has none, preserving channel-registered webhook routes across loader cycles.
  2. createGatewayPluginRequestHandler() reads routes from requireActivePluginRegistry() (current) instead of the stale snapshot captured at handler creation time.

Testing

  • Verified webhook POST returns 200 and messages are delivered after the fix
  • Confirmed routes persist across multiple agent session initializations

Fixes #32275

…ments

Channel plugins (e.g. BlueBubbles) register HTTP webhook routes via
requireActivePluginRegistry() during channel startup. However, the
per-agent plugin loader calls setActivePluginRegistry() with a fresh
registry for each agent, orphaning previously registered httpRoutes.
The gateway HTTP handler then reads from the latest registry and finds
zero routes, returning 404 for all webhook POSTs.

Two changes:
1. setActivePluginRegistry() now carries forward httpRoutes from the
   previous registry when the new one has none, preserving channel-
   registered webhook routes across loader cycles.
2. createGatewayPluginRequestHandler() reads routes from
   requireActivePluginRegistry() (current) instead of the stale
   snapshot captured at handler creation time.

Fixes openclaw#32275
@openclaw-barnacle openclaw-barnacle bot added gateway Gateway runtime size: XS labels Mar 15, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 15, 2026

Greptile Summary

This PR fixes a real bug where channel webhook routes (e.g. BlueBubbles) registered via requireActivePluginRegistry() at channel startup were lost whenever the per-agent plugin loader called setActivePluginRegistry() with a fresh empty registry. The two-part fix — carrying forward httpRoutes in setActivePluginRegistry and reading routes dynamically from requireActivePluginRegistry() in the HTTP request handler — correctly addresses both the stale-snapshot and registry-replacement problems.

Key concerns:

  • In-place mutation of caller's registry (src/plugins/runtime.ts:34): registry.httpRoutes = prevRoutes mutates the object passed by the caller. If the same registry instance is reused due to the cacheKey caching mechanism, or if the caller retains the reference, this silent mutation can produce unexpected state and spurious "route already registered" error diagnostics on the next plugin-load cycle when the channel tries to re-register the same path.
  • Dead registry parameter (src/gateway/server/plugins-http.ts:67): The registry field is still part of the exported public function signature but is never used. Every call-site is forced to pass a value that has no effect, which is misleading. The parameter should be removed from the signature (and call-sites updated) or clearly documented as intentionally ignored.

Confidence Score: 3/5

  • The fix addresses the right problem but the in-place mutation of the incoming registry object in setActivePluginRegistry introduces a subtle correctness risk that should be resolved before merging.
  • The dynamic lookup change in plugins-http.ts is straightforward and safe. The carry-forward logic in runtime.ts is logically sound for the described scenario, but mutating the caller's object rather than creating a copy is fragile: it interacts poorly with the cacheKey registry-reuse path and could cause "already registered" diagnostics on re-registration. These are not show-stoppers, but the mutation pattern is a genuine correctness concern that warrants a fix before merging.
  • Pay close attention to src/plugins/runtime.ts — specifically the in-place mutation at line 34 and how it interacts with registry caching and channel re-registration cycles.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/runtime.ts
Line: 34

Comment:
**Caller's registry object is mutated in-place**

`registry.httpRoutes = prevRoutes` directly modifies the object passed by the caller. If the caller (e.g. the per-agent plugin loader) retains a reference to that registry — or if the same registry instance is reused across multiple `setActivePluginRegistry` calls due to the `cacheKey` mechanism — this silent mutation can produce surprising state. The carried-forward routes now live on what the caller believes is a "fresh, empty" registry, which can cause false-positive "route already registered" diagnostics on the next cycle when the channel plugin tries to re-register the same path.

A safer approach avoids mutating the caller's object:

```suggestion
    registry = { ...registry, httpRoutes: prevRoutes };
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/server/plugins-http.ts
Line: 67

Comment:
**Dead `registry` parameter in public function signature**

`registry` is renamed to `_registry` and is never referenced inside the function. The parameter is still part of the exported public API (`params: { registry: PluginRegistry; ... }`), which means every call-site is required to pass a registry that has absolutely no effect. This misleads callers into believing the provided registry matters for route matching.

Consider removing the `registry` field from the `params` type and the destructuring entirely, and updating call-sites to stop passing it — or, if backward compatibility is needed for now, at minimum add an inline comment on the type noting that this field is intentionally ignored.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 521d117

prevRoutes.length > 0 &&
(!registry.httpRoutes || registry.httpRoutes.length === 0)
) {
registry.httpRoutes = prevRoutes;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caller's registry object is mutated in-place

registry.httpRoutes = prevRoutes directly modifies the object passed by the caller. If the caller (e.g. the per-agent plugin loader) retains a reference to that registry — or if the same registry instance is reused across multiple setActivePluginRegistry calls due to the cacheKey mechanism — this silent mutation can produce surprising state. The carried-forward routes now live on what the caller believes is a "fresh, empty" registry, which can cause false-positive "route already registered" diagnostics on the next cycle when the channel plugin tries to re-register the same path.

A safer approach avoids mutating the caller's object:

Suggested change
registry.httpRoutes = prevRoutes;
registry = { ...registry, httpRoutes: prevRoutes };
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/runtime.ts
Line: 34

Comment:
**Caller's registry object is mutated in-place**

`registry.httpRoutes = prevRoutes` directly modifies the object passed by the caller. If the caller (e.g. the per-agent plugin loader) retains a reference to that registry — or if the same registry instance is reused across multiple `setActivePluginRegistry` calls due to the `cacheKey` mechanism — this silent mutation can produce surprising state. The carried-forward routes now live on what the caller believes is a "fresh, empty" registry, which can cause false-positive "route already registered" diagnostics on the next cycle when the channel plugin tries to re-register the same path.

A safer approach avoids mutating the caller's object:

```suggestion
    registry = { ...registry, httpRoutes: prevRoutes };
```

How can I resolve this? If you propose a fix, please make it concise.

log: SubsystemLogger;
}): PluginHttpRequestHandler {
const { registry, log } = params;
const { registry: _registry, log } = params;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dead registry parameter in public function signature

registry is renamed to _registry and is never referenced inside the function. The parameter is still part of the exported public API (params: { registry: PluginRegistry; ... }), which means every call-site is required to pass a registry that has absolutely no effect. This misleads callers into believing the provided registry matters for route matching.

Consider removing the registry field from the params type and the destructuring entirely, and updating call-sites to stop passing it — or, if backward compatibility is needed for now, at minimum add an inline comment on the type noting that this field is intentionally ignored.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server/plugins-http.ts
Line: 67

Comment:
**Dead `registry` parameter in public function signature**

`registry` is renamed to `_registry` and is never referenced inside the function. The parameter is still part of the exported public API (`params: { registry: PluginRegistry; ... }`), which means every call-site is required to pass a registry that has absolutely no effect. This misleads callers into believing the provided registry matters for route matching.

Consider removing the `registry` field from the `params` type and the destructuring entirely, and updating call-sites to stop passing it — or, if backward compatibility is needed for now, at minimum add an inline comment on the type noting that this field is intentionally ignored.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 521d117ccd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +30 to +34
prevRoutes &&
prevRoutes.length > 0 &&
(!registry.httpRoutes || registry.httpRoutes.length === 0)
) {
registry.httpRoutes = prevRoutes;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow clearing plugin HTTP routes on registry replacement

setActivePluginRegistry now unconditionally reuses the previous httpRoutes array whenever the new registry has none, which makes route removal impossible via normal plugin reloads. Since loadOpenClawPlugins() always calls setActivePluginRegistry (src/plugins/loader.ts:627), reloading with a config/workspace that no longer provides HTTP routes will still keep old handlers alive, so disabled/removed webhook endpoints can continue to be served until process restart.

Useful? React with 👍 / 👎.

andy added 2 commits March 15, 2026 16:12
…eter

- Clone registry instead of mutating caller's object in setActivePluginRegistry()
  to avoid surprising state when the loader retains a reference
- Mark registry param as @deprecated in createGatewayPluginRequestHandler() since
  routes are now read from the active registry at request time
Address Codex review concern about disabled channels retaining stale routes.
The carry-forward is safe because channel providers call their unregister()
teardown when stopped (e.g. on config reload that disables a channel), which
removes their entries from the shared httpRoutes array.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8cbc8db4b9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +36 to +39
prevRoutes.length > 0 &&
(!registry.httpRoutes || registry.httpRoutes.length === 0)
) {
registry = { ...registry, httpRoutes: prevRoutes };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clear stale HTTP routes when registry is replaced

This fallback keeps the previous httpRoutes array whenever a replacement registry has none, so unloading/disabling plugins cannot remove their HTTP endpoints during normal reloads. In practice, routes added via api.registerHttpRoute(...) are static plugin-load registrations (for example in extensions/nostr/index.ts) and do not run a channel-level unregister() teardown, so those handlers can remain reachable after the plugin is turned off until the process restarts. Fresh evidence: static plugin routes are registered at load time, not through the dynamic channel lifecycle assumed by this comment.

Useful? React with 👍 / 👎.

@steipete
Copy link
Copy Markdown
Contributor

Thanks for the diagnosis here.

I landed a replacement fix in #47902 because the current patch still leaves two correctness gaps:

  • runtime webhook routes still get dropped when a later plugin registry already has its own httpRoutes
  • plugin auth preflight still reads the stale startup registry while dispatch reads the live one

Closing this as superseded by #47902.

@steipete steipete closed this Mar 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BlueBubbles webhook message delivery broken in 2026.3.1 (regression from 2026.2.26)

3 participants