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 ?? []), ];