Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save omarshahine/cbcc473ea3165a5717b95a9c10a257ea to your computer and use it in GitHub Desktop.

Select an option

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
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.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" });
+ });
+});
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;
}
#!/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