Last active
March 14, 2026 18:16
-
-
Save omarshahine/cbcc473ea3165a5717b95a9c10a257ea to your computer and use it in GitHub Desktop.
Workaround for openclaw/openclaw#45007 — webhook route registry isolation across jiti VM realms
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
| diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts | |
| index 752908ddf..a0cc80ad8 100644 | |
| --- a/src/plugins/runtime.ts | |
| +++ b/src/plugins/runtime.ts | |
| @@ -8,7 +8,23 @@ type RegistryState = { | |
| version: number; | |
| }; | |
| +// Use process as the cross-realm bridge for the registry singleton. | |
| +// In jiti VM realms, each realm gets its own `globalThis`, so | |
| +// `globalThis[Symbol.for(...)]` resolves to different objects in | |
| +// different realms. Node's `process` is a C++-backed singleton shared | |
| +// across ALL realms, making it a reliable bridge. | |
| +const PROCESS_REGISTRY_KEY = "__openclawLivePluginRegistry"; | |
| +const processGlobal = process as typeof process & { | |
| + [PROCESS_REGISTRY_KEY]?: RegistryState; | |
| +}; | |
| + | |
| const state: RegistryState = (() => { | |
| + // Prefer the process-level singleton (survives jiti realm boundaries). | |
| + if (processGlobal[PROCESS_REGISTRY_KEY]) { | |
| + return processGlobal[PROCESS_REGISTRY_KEY]; | |
| + } | |
| + | |
| + // Fall back to globalThis for non-jiti environments. | |
| const globalState = globalThis as typeof globalThis & { | |
| [REGISTRY_STATE]?: RegistryState; | |
| }; | |
| @@ -19,6 +35,9 @@ const state: RegistryState = (() => { | |
| version: 0, | |
| }; | |
| } | |
| + | |
| + // Publish to process so other realms can find it. | |
| + processGlobal[PROCESS_REGISTRY_KEY] = globalState[REGISTRY_STATE]; | |
| return globalState[REGISTRY_STATE]; | |
| })(); | |
| @@ -26,6 +45,8 @@ export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: str | |
| state.registry = registry; | |
| state.key = cacheKey ?? null; | |
| state.version += 1; | |
| + // Keep process-level bridge in sync so other jiti realms see the update. | |
| + processGlobal[PROCESS_REGISTRY_KEY] = state; | |
| } | |
| export function getActivePluginRegistry(): PluginRegistry | null { | |
| @@ -36,6 +57,7 @@ export function requireActivePluginRegistry(): PluginRegistry { | |
| if (!state.registry) { | |
| state.registry = createEmptyPluginRegistry(); | |
| state.version += 1; | |
| + processGlobal[PROCESS_REGISTRY_KEY] = state; | |
| } | |
| return state.registry; | |
| } |
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
| diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts | |
| index 752908ddf..a0cc80ad8 100644 | |
| --- a/src/plugins/runtime.ts | |
| +++ b/src/plugins/runtime.ts | |
| @@ -8,7 +8,23 @@ type RegistryState = { | |
| version: number; | |
| }; | |
| +// Use process as the cross-realm bridge for the registry singleton. | |
| +// In jiti VM realms, each realm gets its own `globalThis`, so | |
| +// `globalThis[Symbol.for(...)]` resolves to different objects in | |
| +// different realms. Node's `process` is a C++-backed singleton shared | |
| +// across ALL realms, making it a reliable bridge. | |
| +const PROCESS_REGISTRY_KEY = "__openclawLivePluginRegistry"; | |
| +const processGlobal = process as typeof process & { | |
| + [PROCESS_REGISTRY_KEY]?: RegistryState; | |
| +}; | |
| + | |
| const state: RegistryState = (() => { | |
| + // Prefer the process-level singleton (survives jiti realm boundaries). | |
| + if (processGlobal[PROCESS_REGISTRY_KEY]) { | |
| + return processGlobal[PROCESS_REGISTRY_KEY]; | |
| + } | |
| + | |
| + // Fall back to globalThis for non-jiti environments. | |
| const globalState = globalThis as typeof globalThis & { | |
| [REGISTRY_STATE]?: RegistryState; | |
| }; | |
| @@ -19,6 +35,9 @@ const state: RegistryState = (() => { | |
| version: 0, | |
| }; | |
| } | |
| + | |
| + // Publish to process so other realms can find it. | |
| + processGlobal[PROCESS_REGISTRY_KEY] = globalState[REGISTRY_STATE]; | |
| return globalState[REGISTRY_STATE]; | |
| })(); | |
| @@ -26,6 +45,8 @@ export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: str | |
| state.registry = registry; | |
| state.key = cacheKey ?? null; | |
| state.version += 1; | |
| + // Keep process-level bridge in sync so other jiti realms see the update. | |
| + processGlobal[PROCESS_REGISTRY_KEY] = state; | |
| } | |
| export function getActivePluginRegistry(): PluginRegistry | null { | |
| @@ -36,6 +57,7 @@ export function requireActivePluginRegistry(): PluginRegistry { | |
| if (!state.registry) { | |
| state.registry = createEmptyPluginRegistry(); | |
| state.version += 1; | |
| + processGlobal[PROCESS_REGISTRY_KEY] = state; | |
| } | |
| return state.registry; | |
| } | |
| diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts | |
| new file mode 100644 | |
| index 000000000..8f603e9e3 | |
| --- /dev/null | |
| +++ b/src/plugins/runtime.test.ts | |
| @@ -0,0 +1,69 @@ | |
| +import { afterEach, describe, expect, it } from "vitest"; | |
| +import { createEmptyPluginRegistry } from "./registry.js"; | |
| +import { | |
| + getActivePluginRegistry, | |
| + requireActivePluginRegistry, | |
| + setActivePluginRegistry, | |
| +} from "./runtime.js"; | |
| + | |
| +const PROCESS_REGISTRY_KEY = "__openclawLivePluginRegistry"; | |
| +const processGlobal = process as typeof process & { | |
| + [PROCESS_REGISTRY_KEY]?: unknown; | |
| +}; | |
| + | |
| +describe("plugin registry runtime", () => { | |
| + afterEach(() => { | |
| + // Clean up process-level bridge after each test | |
| + delete processGlobal[PROCESS_REGISTRY_KEY]; | |
| + }); | |
| + | |
| + it("publishes registry state to process for cross-realm access", () => { | |
| + // Regression test for #45007: in jiti VM realms, each realm gets its own | |
| + // globalThis, so the Symbol.for("openclaw.pluginRegistryState") singleton | |
| + // resolves to different objects. The process-level bridge ensures all realms | |
| + // share the same registry. | |
| + const registry = requireActivePluginRegistry(); | |
| + expect(registry).toBeDefined(); | |
| + expect(processGlobal[PROCESS_REGISTRY_KEY]).toBeDefined(); | |
| + }); | |
| + | |
| + it("setActivePluginRegistry updates the process-level bridge", () => { | |
| + const newRegistry = createEmptyPluginRegistry(); | |
| + setActivePluginRegistry(newRegistry, "test-key"); | |
| + | |
| + const bridged = processGlobal[PROCESS_REGISTRY_KEY] as { registry: unknown } | undefined; | |
| + expect(bridged).toBeDefined(); | |
| + expect(bridged?.registry).toBe(newRegistry); | |
| + }); | |
| + | |
| + it("requireActivePluginRegistry returns the same instance across calls", () => { | |
| + const first = requireActivePluginRegistry(); | |
| + const second = requireActivePluginRegistry(); | |
| + expect(first).toBe(second); | |
| + }); | |
| + | |
| + it("setActivePluginRegistry makes new registry visible to getActivePluginRegistry", () => { | |
| + const newRegistry = createEmptyPluginRegistry(); | |
| + setActivePluginRegistry(newRegistry); | |
| + expect(getActivePluginRegistry()).toBe(newRegistry); | |
| + }); | |
| + | |
| + it("simulates cross-realm access via process bridge", () => { | |
| + // This simulates what happens when a different jiti realm reads the registry: | |
| + // it can't access the original globalThis, but it CAN read process. | |
| + const registry = createEmptyPluginRegistry(); | |
| + setActivePluginRegistry(registry); | |
| + | |
| + // Simulate another realm reading from process (as our patch does) | |
| + const bridged = processGlobal[PROCESS_REGISTRY_KEY] as { registry: unknown } | undefined; | |
| + expect(bridged?.registry).toBe(registry); | |
| + | |
| + // Mutate via the "other realm" — push a route onto the bridged registry | |
| + const bridgedRegistry = bridged?.registry as { httpRoutes: unknown[] }; | |
| + bridgedRegistry.httpRoutes.push({ path: "/test-webhook", handler: () => {}, auth: "plugin" }); | |
| + | |
| + // The original registry should see the mutation (same object reference) | |
| + expect(getActivePluginRegistry()?.httpRoutes).toHaveLength(1); | |
| + expect(getActivePluginRegistry()?.httpRoutes[0]).toMatchObject({ path: "/test-webhook" }); | |
| + }); | |
| +}); |
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
| diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts | |
| index 752908ddf..a0cc80ad8 100644 | |
| --- a/src/plugins/runtime.ts | |
| +++ b/src/plugins/runtime.ts | |
| @@ -8,7 +8,23 @@ type RegistryState = { | |
| version: number; | |
| }; | |
| +// Use process as the cross-realm bridge for the registry singleton. | |
| +// In jiti VM realms, each realm gets its own `globalThis`, so | |
| +// `globalThis[Symbol.for(...)]` resolves to different objects in | |
| +// different realms. Node's `process` is a C++-backed singleton shared | |
| +// across ALL realms, making it a reliable bridge. | |
| +const PROCESS_REGISTRY_KEY = "__openclawLivePluginRegistry"; | |
| +const processGlobal = process as typeof process & { | |
| + [PROCESS_REGISTRY_KEY]?: RegistryState; | |
| +}; | |
| + | |
| const state: RegistryState = (() => { | |
| + // Prefer the process-level singleton (survives jiti realm boundaries). | |
| + if (processGlobal[PROCESS_REGISTRY_KEY]) { | |
| + return processGlobal[PROCESS_REGISTRY_KEY]; | |
| + } | |
| + | |
| + // Fall back to globalThis for non-jiti environments. | |
| const globalState = globalThis as typeof globalThis & { | |
| [REGISTRY_STATE]?: RegistryState; | |
| }; | |
| @@ -19,6 +35,9 @@ const state: RegistryState = (() => { | |
| version: 0, | |
| }; | |
| } | |
| + | |
| + // Publish to process so other realms can find it. | |
| + processGlobal[PROCESS_REGISTRY_KEY] = globalState[REGISTRY_STATE]; | |
| return globalState[REGISTRY_STATE]; | |
| })(); | |
| @@ -26,6 +45,8 @@ export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: str | |
| state.registry = registry; | |
| state.key = cacheKey ?? null; | |
| state.version += 1; | |
| + // Keep process-level bridge in sync so other jiti realms see the update. | |
| + processGlobal[PROCESS_REGISTRY_KEY] = state; | |
| } | |
| export function getActivePluginRegistry(): PluginRegistry | null { | |
| @@ -36,6 +57,7 @@ export function requireActivePluginRegistry(): PluginRegistry { | |
| if (!state.registry) { | |
| state.registry = createEmptyPluginRegistry(); | |
| state.version += 1; | |
| + processGlobal[PROCESS_REGISTRY_KEY] = state; | |
| } | |
| return state.registry; | |
| } |
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
| #!/bin/sh | |
| # openclaw-patch-webhook-routes | |
| # Patches the gateway and plugin bundles so webhook routes (BlueBubbles, LINE, | |
| # Slack, etc.) resolve correctly across jiti VM realms. | |
| # | |
| # Problem: channel plugins call registerPluginHttpRoute() in a plugin-sdk | |
| # bundle, but the gateway HTTP handler was created with a registry object from | |
| # a different jiti realm. globalThis is NOT shared across realms, so the | |
| # Symbol.for("openclaw.pluginRegistryState") bridge fails silently — routes | |
| # are registered on one realm's global while the handler reads from another. | |
| # | |
| # Fix: use process.__openclawLivePluginRegistry as the cross-realm bridge. | |
| # Node's process object is a C++-backed singleton shared across all VM realms. | |
| # - Registration side: after routes.push(), store registry on process | |
| # - Handler side: read from process instead of globalThis Symbol | |
| # | |
| # Workaround for https://github.com/openclaw/openclaw/issues/45007 | |
| # Also see: #45352, #45598, #45445 | |
| # Remove this script once the upstream fix is released. | |
| # | |
| # Usage: openclaw-patch-webhook-routes [--check] | |
| # --check Only report whether the patch is needed; don't modify files. | |
| set -eu | |
| ROOT_DIR="$(dirname "$(realpath "$(command -v openclaw)")")" | |
| DIST="$ROOT_DIR/dist" | |
| CHECK_ONLY=false | |
| if [ "${1:-}" = "--check" ]; then | |
| CHECK_ONLY=true | |
| fi | |
| # Check if already patched (look for the process bridge marker) | |
| if grep -q '__openclawLivePluginRegistry' "$DIST"/gateway-cli-*.js 2>/dev/null; then | |
| echo "OK: Patch already applied." | |
| exit 0 | |
| fi | |
| if [ "$CHECK_ONLY" = true ]; then | |
| echo "NEEDS PATCH: webhook route registry isolation not fixed" | |
| exit 1 | |
| fi | |
| PATCHED=0 | |
| # --- Part 1: Patch gateway handler bundles --- | |
| # Makes the HTTP request handler read routes from process.__openclawLivePluginRegistry | |
| # instead of the stale closure-captured registry. | |
| for f in "$DIST"/gateway-cli-*.js; do | |
| [ -f "$f" ] || continue | |
| grep -q 'createGatewayPluginRequestHandler' "$f" 2>/dev/null || continue | |
| echo "Patching handler: $(basename "$f")" | |
| python3 -c " | |
| import sys | |
| f = '$f' | |
| with open(f, 'r') as fh: | |
| content = fh.read() | |
| if '__openclawLivePluginRegistry' in content: | |
| print(' Already patched') | |
| sys.exit(0) | |
| changed = False | |
| # Patch createGatewayPluginRequestHandler to read from process bridge | |
| old_handler = 'if ((registry.httpRoutes ?? []).length === 0) return false;' | |
| new_handler = 'const _liveReg = (process.__openclawLivePluginRegistry ?? registry); if ((_liveReg.httpRoutes ?? []).length === 0) return false;' | |
| if old_handler in content: | |
| content = content.replace(old_handler, new_handler) | |
| changed = True | |
| old_match = 'const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext);' | |
| new_match = 'const matchedRoutes = findMatchingPluginHttpRoutes(_liveReg, pathContext);' | |
| if old_match in content: | |
| content = content.replace(old_match, new_match) | |
| changed = True | |
| # Patch shouldEnforceGatewayAuthForPluginPath | |
| old_auth = 'return matchedPluginRoutesRequireGatewayAuth(findMatchingPluginHttpRoutes(registry, pathContext));' | |
| new_auth = 'return matchedPluginRoutesRequireGatewayAuth(findMatchingPluginHttpRoutes((process.__openclawLivePluginRegistry ?? registry), pathContext));' | |
| if old_auth in content: | |
| content = content.replace(old_auth, new_auth) | |
| changed = True | |
| if not changed: | |
| print(' WARN: no patterns matched (may be a different version)') | |
| sys.exit(1) | |
| with open(f, 'w') as fh: | |
| fh.write(content) | |
| print(' OK') | |
| " | |
| PATCHED=$((PATCHED + 1)) | |
| done | |
| # --- Part 2: Patch all registerPluginHttpRoute copies --- | |
| # After routes.push(entry), store the registry on process so the handler can find it. | |
| REG_PATCHED=0 | |
| for f in "$DIST"/*.js "$DIST"/plugin-sdk/*.js; do | |
| [ -f "$f" ] || continue | |
| grep -q 'function registerPluginHttpRoute' "$f" 2>/dev/null || continue | |
| grep -q '__openclawLivePluginRegistry' "$f" 2>/dev/null && continue | |
| echo "Patching registration: $(basename "$f")" | |
| python3 -c " | |
| import sys | |
| f = '$f' | |
| with open(f, 'r') as fh: | |
| content = fh.read() | |
| old = '\troutes.push(entry);\n\treturn () => {' | |
| new = '\troutes.push(entry);\n\tprocess.__openclawLivePluginRegistry = registry;\n\treturn () => {' | |
| if old not in content: | |
| print(' WARN: pattern not found') | |
| sys.exit(1) | |
| content = content.replace(old, new, 1) | |
| with open(f, 'w') as fh: | |
| fh.write(content) | |
| print(' OK') | |
| " | |
| REG_PATCHED=$((REG_PATCHED + 1)) | |
| done | |
| TOTAL=$((PATCHED + REG_PATCHED)) | |
| if [ "$TOTAL" -eq 0 ]; then | |
| echo "WARNING: No files were patched." | |
| exit 1 | |
| fi | |
| echo "" | |
| echo "Patched $PATCHED handler(s) + $REG_PATCHED registration(s). Restart the gateway: openclaw gateway restart" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment