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 #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).
This commit is contained in:
openperf
2026-05-05 18:26:26 +08:00
parent 1c331a814a
commit 7b7676be0d
3 changed files with 132 additions and 0 deletions

View File

@@ -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

View File

@@ -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,
}
: {}),
};
}

View File

@@ -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();