From f07844450c05a1968985f0dad97471f2b7d2aa7f Mon Sep 17 00:00:00 2001 From: Intern Dev Date: Mon, 27 Apr 2026 16:58:49 -0400 Subject: [PATCH] Prevent disabled plugins from warming the gateway plugin graph A local containment profile uses plugins.enabled=false to stop plugin and channel runtime churn. The previous startup path still built plugin lookup tables and doctor stale scans despite the global disable, which made the switch noisy and slow. Constraint: plugins.enabled=false must leave channel blocker warnings intact while treating stale plugin config as inert. Rejected: Clear user plugin config automatically | would mutate a reversible containment setting. Confidence: high Scope-risk: narrow Directive: Do not reintroduce plugin registry discovery before checking plugins.enabled. Tested: pnpm test src/gateway/server-startup-plugins.test.ts src/config/plugin-auto-enable.core.test.ts src/commands/doctor/shared/stale-plugin-config.test.ts src/commands/doctor/shared/preview-warnings.test.ts Tested: pnpm check:changed Tested: pnpm build --- .../doctor/shared/preview-warnings.test.ts | 30 ++++++++++++ .../doctor/shared/preview-warnings.ts | 2 +- .../doctor/shared/stale-plugin-config.test.ts | 21 +++++++++ .../doctor/shared/stale-plugin-config.ts | 9 ++++ src/config/plugin-auto-enable.core.test.ts | 15 ++++++ src/config/plugin-auto-enable.shared.ts | 10 ++++ src/gateway/server-startup-plugins.test.ts | 46 +++++++++++++++++++ src/gateway/server-startup-plugins.ts | 20 ++++---- 8 files changed, 143 insertions(+), 10 deletions(-) diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index b60510bbdd8..ff63ab6424b 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -362,4 +362,34 @@ describe("doctor preview warnings", () => { ]); expect(warnings[0]).not.toContain("first-time setup mode"); }); + + it("keeps global plugin-disable blocker warnings but omits stale plugin cleanup warnings", async () => { + manifestState.plugins = [channelManifest("telegram", "telegram")]; + + const warnings = await collectDoctorPreviewWarnings({ + cfg: { + channels: { + telegram: { + botToken: "123:abc", + groupPolicy: "allowlist", + }, + }, + plugins: { + enabled: false, + allow: ["acpx"], + entries: { + acpx: { enabled: true }, + }, + }, + }, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(warnings).toEqual([ + expect.stringContaining( + "channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally.", + ), + ]); + expect(warnings.join("\n")).not.toContain("stale plugin reference"); + }); }); diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index cb34be2ecb6..03a16ed75e8 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -123,7 +123,7 @@ export async function collectDoctorPreviewWarnings(params: { } } - if (hasPluginConfig || hasChannelConfig) { + if ((hasPluginConfig || hasChannelConfig) && params.cfg.plugins?.enabled !== false) { const { collectStalePluginConfigWarnings, isStalePluginAutoRepairBlocked, diff --git a/src/commands/doctor/shared/stale-plugin-config.test.ts b/src/commands/doctor/shared/stale-plugin-config.test.ts index 0bfedc17537..e1e3ca319a3 100644 --- a/src/commands/doctor/shared/stale-plugin-config.test.ts +++ b/src/commands/doctor/shared/stale-plugin-config.test.ts @@ -198,6 +198,27 @@ describe("doctor stale plugin config helpers", () => { expect(maybeRepairStalePluginConfig(cfg)).toEqual({ config: cfg, changes: [] }); }); + it("treats stale plugin refs as inert while plugins are globally disabled", () => { + const cfg = { + plugins: { + enabled: false, + allow: ["acpx"], + entries: { + acpx: { enabled: true }, + }, + }, + channels: { + "openclaw-weixin": { + enabled: true, + }, + }, + } as OpenClawConfig; + + expect(scanStalePluginConfig(cfg)).toEqual([]); + expect(maybeRepairStalePluginConfig(cfg)).toEqual({ config: cfg, changes: [] }); + expect(manifestRegistry.loadPluginManifestRegistry).not.toHaveBeenCalled(); + }); + it("uses missing persisted install records as stale channel evidence", () => { installedPluginIndexMocks.loadInstalledPluginIndexInstallRecordsSync.mockReturnValue({ "openclaw-weixin": { diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index 7414302d76e..eec3ca8ae25 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -74,6 +74,9 @@ export function isStalePluginAutoRepairBlocked( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, ): boolean { + if (cfg.plugins?.enabled === false) { + return false; + } return collectPluginRegistryState(cfg, env).hasDiscoveryErrors; } @@ -81,6 +84,9 @@ export function scanStalePluginConfig( cfg: OpenClawConfig, env?: NodeJS.ProcessEnv, ): StalePluginConfigHit[] { + if (cfg.plugins?.enabled === false) { + return []; + } return scanStalePluginConfigWithState(cfg, collectPluginRegistryState(cfg, env)); } @@ -268,6 +274,9 @@ export function maybeRepairStalePluginConfig( config: OpenClawConfig; changes: string[]; } { + if (cfg.plugins?.enabled === false) { + return { config: cfg, changes: [] }; + } const registryState = collectPluginRegistryState(cfg, env); if (registryState.hasDiscoveryErrors) { return { config: cfg, changes: [] }; diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index 73137de6d0d..606fc5f421c 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -720,6 +720,21 @@ describe("applyPluginAutoEnable core", () => { }); it("skips when plugins are globally disabled", () => { + expect( + detectPluginAutoEnableCandidates({ + config: { + channels: { slack: { botToken: "x" } }, + plugins: { + enabled: false, + allow: ["slack"], + entries: { slack: { config: { botToken: "x" } } }, + }, + }, + env, + manifestRegistry: makeRegistry([{ id: "slack", channels: ["slack"] }]), + }), + ).toEqual([]); + const result = applyPluginAutoEnable({ config: { channels: { slack: { botToken: "x" } }, diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 5262464cd48..1caf536a915 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -466,7 +466,14 @@ function hasConfiguredProviderModelOrHarness(cfg: OpenClawConfig, env: NodeJS.Pr return hasConfiguredEmbeddedHarnessRuntime(cfg, env); } +function arePluginsGloballyDisabled(cfg: OpenClawConfig): boolean { + return cfg.plugins?.enabled === false; +} + function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + if (arePluginsGloballyDisabled(cfg)) { + return false; + } if (hasPluginAllowlistWithMaterialEntries(cfg)) { return true; } @@ -493,6 +500,9 @@ export function configMayNeedPluginAutoEnable( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, ): boolean { + if (arePluginsGloballyDisabled(cfg)) { + return false; + } if (hasPluginAllowlistWithMaterialEntries(cfg)) { return true; } diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index cc65e867ee4..5360daf8bf0 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -410,4 +410,50 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { "bundledRuntimeDepsInstaller", ); }); + + it("bypasses plugin lookup and runtime-deps staging when plugins are globally disabled", async () => { + const cfg = { + channels: { + telegram: { + botToken: "token", + }, + }, + plugins: { + enabled: false, + allow: ["telegram"], + entries: { + telegram: { enabled: true }, + }, + }, + } as OpenClawConfig; + const log = createLog(); + const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js"); + + await expect( + prepareGatewayPluginBootstrap({ + cfgAtStart: cfg, + startupRuntimeConfig: cfg, + minimalTestGateway: false, + log, + }), + ).resolves.toMatchObject({ + startupPluginIds: [], + deferredConfiguredChannelPluginIds: [], + pluginLookUpTable: undefined, + baseGatewayMethods: ["ping"], + }); + + expect(loadPluginLookUpTable).not.toHaveBeenCalled(); + expect(scanBundledPluginRuntimeDeps).not.toHaveBeenCalled(); + expect(repairBundledRuntimeDepsInstallRootAsync).not.toHaveBeenCalled(); + expect(loadGatewayStartupPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + pluginIds: [], + pluginLookUpTable: undefined, + preferSetupRuntimeForChannelPlugins: false, + suppressPluginInfoLogs: false, + }), + ); + }); }); diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 70af38709e5..b9f7fbe87f8 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -155,17 +155,19 @@ export async function prepareGatewayPluginBootstrap(params: { : {}), }).config, }); + const pluginsGloballyDisabled = gatewayPluginConfig.plugins?.enabled === false; const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfig); const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfig, defaultAgentId); - const pluginLookUpTable = params.minimalTestGateway - ? undefined - : loadPluginLookUpTable({ - config: gatewayPluginConfig, - workspaceDir: defaultWorkspaceDir, - env: process.env, - activationSourceConfig, - metadataSnapshot: params.pluginMetadataSnapshot, - }); + const pluginLookUpTable = + params.minimalTestGateway || pluginsGloballyDisabled + ? undefined + : loadPluginLookUpTable({ + config: gatewayPluginConfig, + workspaceDir: defaultWorkspaceDir, + env: process.env, + activationSourceConfig, + metadataSnapshot: params.pluginMetadataSnapshot, + }); const deferredConfiguredChannelPluginIds = [ ...(pluginLookUpTable?.startup.configuredDeferredChannelPluginIds ?? []), ];