diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d52c0e37b..addeec3606a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -481,6 +481,7 @@ Docs: https://docs.openclaw.ai - Dependencies: bump transitive `basic-ftp` to 5.3.1 so the runtime lockfile no longer includes the vulnerable 5.3.0 build flagged by the production dependency audit. (#78637) Thanks @sallyom. - Agents/compaction: clamp compaction summary reserve tokens to each model's output limit so high-context compaction no longer requests invalid `max_tokens` values. (#54392) Thanks @adzendo. - Agents/subagents: have completed session-mode subagent registry rows honor `agents.defaults.subagents.archiveAfterMinutes` (default 60 minutes; same knob run-mode already uses for `archiveAtMs`) instead of a hardcoded 5-minute TTL, so `subagents list` and other registry-backed surfaces still show recently-completed runs and operators have one consistent retention knob across spawn modes. (#78263) Thanks @arniesaha. +- Plugins/channel setup: fix `setChannelRuntime` being silently dropped from non-bundled external plugin setup entries — external channel plugins that export `{ plugin, setChannelRuntime }` from their setup entry now have the runtime setter invoked, so the runtime initializer the provider polls for is set before the channel starts, preventing a poll timeout and gateway crash loop when the plugin opts into deferred startup loading. Fixes #77779. (#77799) Thanks @openperf. ## 2026.5.3-1 diff --git a/src/plugins/loader-channel-setup.ts b/src/plugins/loader-channel-setup.ts index c9cef5d9f8d..dc79e50e6de 100644 --- a/src/plugins/loader-channel-setup.ts +++ b/src/plugins/loader-channel-setup.ts @@ -174,12 +174,18 @@ export function resolveSetupChannelRegistration(moduleExport: unknown): { } const setup = resolved as { plugin?: unknown; + setChannelRuntime?: unknown; }; if (!setup.plugin || typeof setup.plugin !== "object") { return {}; } return { plugin: setup.plugin as ChannelPlugin, + ...(typeof setup.setChannelRuntime === "function" + ? { + setChannelRuntime: setup.setChannelRuntime as (runtime: PluginRuntime) => void, + } + : {}), }; } diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 4350363a26a..f96cfd9c08b 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -5080,6 +5080,131 @@ module.exports = { expect(fs.existsSync(runtimeMarker)).toBe(false); }); + it("invokes setChannelRuntime from a non-bundled setup entry for a configured deferred channel during startup Phase 1", () => { + // Regression test for #77779. When a configured external channel plugin opts into + // deferred full loading (startupDeferConfiguredChannelFullLoadUntilAfterListen) the + // loader runs in setup-runtime mode during Phase 1 (before gateway listen). In that + // phase api.registerChannel is active and writes the plugin into registry.channels, + // so the channel provider starts immediately — before Phase 2 runs register(). Any + // runtime initializer (e.g. setWeixinRuntime) that the provider polls for must + // therefore be invoked via setChannelRuntime in the setup entry. Before this fix, + // resolveSetupChannelRegistration silently dropped setChannelRuntime from non-bundled + // {plugin, setChannelRuntime} exports, leaving the runtime unset and causing + // waitForWeixinRuntime() to time out. + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const runtimeMarker = path.join(makeTempDir(), "deferred-configured-setup-runtime-applied.txt"); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/non-bundled-deferred-setup-runtime-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + startup: { + deferConfiguredChannelFullLoadUntilAfterListen: true, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "non-bundled-deferred-setup-runtime-test", + configSchema: { type: "object", properties: {} }, + channels: ["non-bundled-deferred-setup-runtime-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `module.exports = { + id: "non-bundled-deferred-setup-runtime-test", + register(api) { + api.registerChannel({ + plugin: { + id: "non-bundled-deferred-setup-runtime-test", + meta: { + id: "non-bundled-deferred-setup-runtime-test", + label: "Non-Bundled Deferred Setup Runtime Test", + selectionLabel: "Non-Bundled Deferred Setup Runtime Test", + docsPath: "/channels/non-bundled-deferred-setup-runtime-test", + blurb: "full channel entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { listAccountIds: () => ["default"], resolveAccount: () => ({ accountId: "default", token: "configured" }) }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `module.exports = { + plugin: { + id: "non-bundled-deferred-setup-runtime-test", + meta: { + id: "non-bundled-deferred-setup-runtime-test", + label: "Non-Bundled Deferred Setup Runtime Test", + selectionLabel: "Non-Bundled Deferred Setup Runtime Test", + docsPath: "/channels/non-bundled-deferred-setup-runtime-test", + blurb: "setup entry", + }, + capabilities: { chatTypes: ["direct"] }, + config: { listAccountIds: () => ["default"], resolveAccount: () => ({ accountId: "default", token: "configured" }) }, + outbound: { deliveryMode: "direct" }, + }, + setChannelRuntime: () => { + require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "applied", "utf-8"); + }, +};`, + "utf-8", + ); + + // Phase 1: preferSetupRuntimeForChannelPlugins=true simulates gateway startup when + // at least one deferred configured channel plugin is present. The configured channel + // opts into deferral, so setup-runtime mode is used. setup-entry.cjs is loaded and + // setChannelRuntime must be invoked. The channel is also written into registry.channels + // (runtimeChannel=true in setup-runtime), so the provider starts in Phase 1. + const registry = loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "non-bundled-deferred-setup-runtime-test": { + enabled: true, + token: "configured", + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["non-bundled-deferred-setup-runtime-test"], + }, + }, + }); + + // setChannelRuntime must have been called so that any runtime initializer the + // provider polls for (e.g. waitForWeixinRuntime) is satisfied before the provider + // times out. + expect(fs.existsSync(runtimeMarker)).toBe(true); + // The channel is registered in registry.channels during Phase 1 (not deferred to + // Phase 2), confirming the provider would start and need setChannelRuntime. + expect(registry.channels).toHaveLength(1); + expect(registry.channels[0]?.plugin.id).toBe("non-bundled-deferred-setup-runtime-test"); + }); + it("isolates loadSetupPlugin errors as per-plugin diagnostics instead of crashing registry load", () => { useNoBundledPlugins(); const pluginDir = makeTempDir();