Skip to content

Commit 7b7676b

Browse files
committed
fix(plugins): forward setChannelRuntime from non-bundled external setup entries
resolveSetupChannelRegistration handled the non-bundled setup-entry format ({plugin, setChannelRuntime}) by only extracting `plugin`, silently dropping `setChannelRuntime`. Root cause: in setup-runtime mode (Phase 1 of deferred gateway startup), registerChannel is always active (runtimeChannel=true) and writes the channel plugin into registry.channels immediately. This means the channel provider starts in Phase 1, before Phase 2's register() call. Any runtime initializer the provider polls for (e.g. waitForWeixinRuntime) must therefore be set via setChannelRuntime in the setup entry — it cannot wait for Phase 2. For external plugins using the plain-object setup entry format the setter was silently discarded, leaving the runtime uninitialized when the provider started. waitForWeixinRuntime() would time out after 10 s and the gateway entered a crash loop. Phase 2 eventually ran register() with a valid api.runtime but by then the channel had already exited. Fixes openclaw#77779. Mirror the existing bundled-entry handling: extract setChannelRuntime from the non-bundled path and include it in the return value so loader.ts:2218 can invoke it before the channel is registered. Regression test covers the exact failure path: configured channel with startupDeferConfiguredChannelFullLoadUntilAfterListen and preferSetupRuntimeForChannelPlugins, non-bundled setup entry exporting {plugin, setChannelRuntime}. Asserts both that the setter is invoked and that the channel lands in registry.channels in Phase 1 (confirming the provider would start before Phase 2).
1 parent 1c331a8 commit 7b7676b

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ Docs: https://docs.openclaw.ai
481481
- 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.
482482
- 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.
483483
- 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.
484+
- 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.
484485

485486
## 2026.5.3-1
486487

src/plugins/loader-channel-setup.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,18 @@ export function resolveSetupChannelRegistration(moduleExport: unknown): {
174174
}
175175
const setup = resolved as {
176176
plugin?: unknown;
177+
setChannelRuntime?: unknown;
177178
};
178179
if (!setup.plugin || typeof setup.plugin !== "object") {
179180
return {};
180181
}
181182
return {
182183
plugin: setup.plugin as ChannelPlugin,
184+
...(typeof setup.setChannelRuntime === "function"
185+
? {
186+
setChannelRuntime: setup.setChannelRuntime as (runtime: PluginRuntime) => void,
187+
}
188+
: {}),
183189
};
184190
}
185191

src/plugins/loader.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5080,6 +5080,131 @@ module.exports = {
50805080
expect(fs.existsSync(runtimeMarker)).toBe(false);
50815081
});
50825082

5083+
it("invokes setChannelRuntime from a non-bundled setup entry for a configured deferred channel during startup Phase 1", () => {
5084+
// Regression test for #77779. When a configured external channel plugin opts into
5085+
// deferred full loading (startupDeferConfiguredChannelFullLoadUntilAfterListen) the
5086+
// loader runs in setup-runtime mode during Phase 1 (before gateway listen). In that
5087+
// phase api.registerChannel is active and writes the plugin into registry.channels,
5088+
// so the channel provider starts immediately — before Phase 2 runs register(). Any
5089+
// runtime initializer (e.g. setWeixinRuntime) that the provider polls for must
5090+
// therefore be invoked via setChannelRuntime in the setup entry. Before this fix,
5091+
// resolveSetupChannelRegistration silently dropped setChannelRuntime from non-bundled
5092+
// {plugin, setChannelRuntime} exports, leaving the runtime unset and causing
5093+
// waitForWeixinRuntime() to time out.
5094+
useNoBundledPlugins();
5095+
const pluginDir = makeTempDir();
5096+
const runtimeMarker = path.join(makeTempDir(), "deferred-configured-setup-runtime-applied.txt");
5097+
5098+
fs.writeFileSync(
5099+
path.join(pluginDir, "package.json"),
5100+
JSON.stringify(
5101+
{
5102+
name: "@openclaw/non-bundled-deferred-setup-runtime-test",
5103+
openclaw: {
5104+
extensions: ["./index.cjs"],
5105+
setupEntry: "./setup-entry.cjs",
5106+
startup: {
5107+
deferConfiguredChannelFullLoadUntilAfterListen: true,
5108+
},
5109+
},
5110+
},
5111+
null,
5112+
2,
5113+
),
5114+
"utf-8",
5115+
);
5116+
fs.writeFileSync(
5117+
path.join(pluginDir, "openclaw.plugin.json"),
5118+
JSON.stringify(
5119+
{
5120+
id: "non-bundled-deferred-setup-runtime-test",
5121+
configSchema: { type: "object", properties: {} },
5122+
channels: ["non-bundled-deferred-setup-runtime-test"],
5123+
},
5124+
null,
5125+
2,
5126+
),
5127+
"utf-8",
5128+
);
5129+
fs.writeFileSync(
5130+
path.join(pluginDir, "index.cjs"),
5131+
`module.exports = {
5132+
id: "non-bundled-deferred-setup-runtime-test",
5133+
register(api) {
5134+
api.registerChannel({
5135+
plugin: {
5136+
id: "non-bundled-deferred-setup-runtime-test",
5137+
meta: {
5138+
id: "non-bundled-deferred-setup-runtime-test",
5139+
label: "Non-Bundled Deferred Setup Runtime Test",
5140+
selectionLabel: "Non-Bundled Deferred Setup Runtime Test",
5141+
docsPath: "/channels/non-bundled-deferred-setup-runtime-test",
5142+
blurb: "full channel entry",
5143+
},
5144+
capabilities: { chatTypes: ["direct"] },
5145+
config: { listAccountIds: () => ["default"], resolveAccount: () => ({ accountId: "default", token: "configured" }) },
5146+
outbound: { deliveryMode: "direct" },
5147+
},
5148+
});
5149+
},
5150+
};`,
5151+
"utf-8",
5152+
);
5153+
fs.writeFileSync(
5154+
path.join(pluginDir, "setup-entry.cjs"),
5155+
`module.exports = {
5156+
plugin: {
5157+
id: "non-bundled-deferred-setup-runtime-test",
5158+
meta: {
5159+
id: "non-bundled-deferred-setup-runtime-test",
5160+
label: "Non-Bundled Deferred Setup Runtime Test",
5161+
selectionLabel: "Non-Bundled Deferred Setup Runtime Test",
5162+
docsPath: "/channels/non-bundled-deferred-setup-runtime-test",
5163+
blurb: "setup entry",
5164+
},
5165+
capabilities: { chatTypes: ["direct"] },
5166+
config: { listAccountIds: () => ["default"], resolveAccount: () => ({ accountId: "default", token: "configured" }) },
5167+
outbound: { deliveryMode: "direct" },
5168+
},
5169+
setChannelRuntime: () => {
5170+
require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "applied", "utf-8");
5171+
},
5172+
};`,
5173+
"utf-8",
5174+
);
5175+
5176+
// Phase 1: preferSetupRuntimeForChannelPlugins=true simulates gateway startup when
5177+
// at least one deferred configured channel plugin is present. The configured channel
5178+
// opts into deferral, so setup-runtime mode is used. setup-entry.cjs is loaded and
5179+
// setChannelRuntime must be invoked. The channel is also written into registry.channels
5180+
// (runtimeChannel=true in setup-runtime), so the provider starts in Phase 1.
5181+
const registry = loadOpenClawPlugins({
5182+
cache: false,
5183+
preferSetupRuntimeForChannelPlugins: true,
5184+
config: {
5185+
channels: {
5186+
"non-bundled-deferred-setup-runtime-test": {
5187+
enabled: true,
5188+
token: "configured",
5189+
},
5190+
},
5191+
plugins: {
5192+
load: { paths: [pluginDir] },
5193+
allow: ["non-bundled-deferred-setup-runtime-test"],
5194+
},
5195+
},
5196+
});
5197+
5198+
// setChannelRuntime must have been called so that any runtime initializer the
5199+
// provider polls for (e.g. waitForWeixinRuntime) is satisfied before the provider
5200+
// times out.
5201+
expect(fs.existsSync(runtimeMarker)).toBe(true);
5202+
// The channel is registered in registry.channels during Phase 1 (not deferred to
5203+
// Phase 2), confirming the provider would start and need setChannelRuntime.
5204+
expect(registry.channels).toHaveLength(1);
5205+
expect(registry.channels[0]?.plugin.id).toBe("non-bundled-deferred-setup-runtime-test");
5206+
});
5207+
50835208
it("isolates loadSetupPlugin errors as per-plugin diagnostics instead of crashing registry load", () => {
50845209
useNoBundledPlugins();
50855210
const pluginDir = makeTempDir();

0 commit comments

Comments
 (0)