From 96ed010a37d372246c702ad2cb0e63210a51d017 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 16 Mar 2026 13:55:53 +0000 Subject: [PATCH] Gateway: gate deferred channel startup behind opt-in --- src/gateway/server.impl.ts | 10 +-- src/plugins/channel-plugin-ids.ts | 24 +++++++ src/plugins/loader.test.ts | 110 ++++++++++++++++++++++++++++++ src/plugins/loader.ts | 12 +++- src/plugins/manifest-registry.ts | 4 ++ src/plugins/manifest.ts | 9 +++ 6 files changed, 163 insertions(+), 6 deletions(-) diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 76f60728cdc..350172bcee4 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -45,7 +45,7 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; -import { resolveConfiguredChannelPluginIds } from "../plugins/channel-plugin-ids.js"; +import { resolveConfiguredDeferredChannelPluginIds } from "../plugins/channel-plugin-ids.js"; import { getGlobalHookRunner, runGlobalGatewayStopSafely } from "../plugins/hook-runner-global.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { createPluginRuntime } from "../plugins/runtime/index.js"; @@ -474,9 +474,9 @@ export async function startGatewayServer( initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); - const configuredChannelPluginIds = minimalTestGateway + const deferredConfiguredChannelPluginIds = minimalTestGateway ? [] - : resolveConfiguredChannelPluginIds({ + : resolveConfiguredDeferredChannelPluginIds({ config: cfgAtStart, workspaceDir: defaultWorkspaceDir, env: process.env, @@ -492,7 +492,7 @@ export async function startGatewayServer( log, coreGatewayHandlers, baseMethods, - preferSetupRuntimeForChannelPlugins: configuredChannelPluginIds.length > 0, + preferSetupRuntimeForChannelPlugins: deferredConfiguredChannelPluginIds.length > 0, })); } const channelLogs = Object.fromEntries( @@ -951,7 +951,7 @@ export async function startGatewayServer( let browserControl: Awaited> = null; if (!minimalTestGateway) { - if (configuredChannelPluginIds.length > 0) { + if (deferredConfiguredChannelPluginIds.length > 0) { ({ pluginRegistry } = loadGatewayPlugins({ cfg: cfgAtStart, workspaceDir: defaultWorkspaceDir, diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 3cbf761bbd2..b5a22f15b63 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -29,3 +29,27 @@ export function resolveConfiguredChannelPluginIds(params: { } return resolveChannelPluginIds(params).filter((pluginId) => configuredChannelIds.has(pluginId)); } + +export function resolveConfiguredDeferredChannelPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string[] { + const configuredChannelIds = new Set( + listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()), + ); + if (configuredChannelIds.size === 0) { + return []; + } + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) + .plugins.filter( + (plugin) => + plugin.channels.some((channelId) => configuredChannelIds.has(channelId)) && + plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, + ) + .map((plugin) => plugin.id); +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 587fad30767..a91b6c939ab 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -2043,6 +2043,9 @@ module.exports = { openclaw: { extensions: ["./index.cjs"], setupEntry: "./setup-entry.cjs", + startup: { + deferConfiguredChannelFullLoadUntilAfterListen: true, + }, }, }, null, @@ -2137,6 +2140,113 @@ module.exports = { expect(registry.channels).toHaveLength(1); }); + it("does not prefer setupEntry for configured channel loads without startup opt-in", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(makeTempDir(), "full-loaded.txt"); + const setupMarker = path.join(makeTempDir(), "setup-loaded.txt"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-runtime-not-preferred-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-runtime-not-preferred-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-runtime-not-preferred-test"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + id: "setup-runtime-not-preferred-test", + register(api) { + api.registerChannel({ + plugin: { + id: "setup-runtime-not-preferred-test", + meta: { + id: "setup-runtime-not-preferred-test", + label: "Setup Runtime Not Preferred Test", + selectionLabel: "Setup Runtime Not Preferred Test", + docsPath: "/channels/setup-runtime-not-preferred-test", + blurb: "full entry should still load without explicit startup opt-in", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + }, +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + plugin: { + id: "setup-runtime-not-preferred-test", + meta: { + id: "setup-runtime-not-preferred-test", + label: "Setup Runtime Not Preferred Test", + selectionLabel: "Setup Runtime Not Preferred Test", + docsPath: "/channels/setup-runtime-not-preferred-test", + blurb: "setup runtime not preferred", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ accountId: "default", token: "configured" }), + }, + outbound: { deliveryMode: "direct" }, + }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + preferSetupRuntimeForChannelPlugins: true, + config: { + channels: { + "setup-runtime-not-preferred-test": { + enabled: true, + }, + }, + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-not-preferred-test"], + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(true); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(1); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 72ade07c487..dc3bf5139c6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -54,6 +54,10 @@ export type PluginLoadOptions = { mode?: "full" | "validate"; onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; + /** + * Prefer `setupEntry` for configured channel plugins that explicitly opt in + * via package metadata because their setup entry covers the pre-listen startup surface. + */ preferSetupRuntimeForChannelPlugins?: boolean; activate?: boolean; }; @@ -449,6 +453,7 @@ function resolveSetupChannelRegistration(moduleExport: unknown): { function shouldLoadChannelPluginInSetupRuntime(params: { manifestChannels: string[]; setupSource?: string; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; cfg: OpenClawConfig; env: NodeJS.ProcessEnv; preferSetupRuntimeForChannelPlugins?: boolean; @@ -456,7 +461,10 @@ function shouldLoadChannelPluginInSetupRuntime(params: { if (!params.setupSource || params.manifestChannels.length === 0) { return false; } - if (params.preferSetupRuntimeForChannelPlugins) { + if ( + params.preferSetupRuntimeForChannelPlugins && + params.startupDeferConfiguredChannelFullLoadUntilAfterListen === true + ) { return true; } return !params.manifestChannels.some((channelId) => @@ -1076,6 +1084,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi shouldLoadChannelPluginInSetupRuntime({ manifestChannels: manifestRecord.channels, setupSource: manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, cfg, env, preferSetupRuntimeForChannelPlugins, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index ea646f38797..7a5c10d67f0 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -51,6 +51,7 @@ export type PluginManifestRecord = { rootDir: string; source: string; setupSource?: string; + startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; manifestPath: string; schemaCacheKey?: string; configSchema?: Record; @@ -168,6 +169,9 @@ function buildRecord(params: { rootDir: params.candidate.rootDir, source: params.candidate.source, setupSource: params.candidate.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + params.candidate.packageManifest?.startup?.deferConfiguredChannelFullLoadUntilAfterListen === + true, manifestPath: params.manifestPath, schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index d330b982ce1..dd8615d7350 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -242,11 +242,20 @@ export type PluginPackageInstall = { defaultChoice?: "npm" | "local"; }; +export type OpenClawPackageStartup = { + /** + * Opt-in for channel plugins whose `setupEntry` fully covers the gateway + * startup surface needed before the server starts listening. + */ + deferConfiguredChannelFullLoadUntilAfterListen?: boolean; +}; + export type OpenClawPackageManifest = { extensions?: string[]; setupEntry?: string; channel?: PluginPackageChannel; install?: PluginPackageInstall; + startup?: OpenClawPackageStartup; }; export const DEFAULT_PLUGIN_ENTRY_CANDIDATES = [