From 94275f13fb776bfa071ed80949bf1ef880d3d61b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 18:48:20 +0100 Subject: [PATCH] fix: keep disabled channel doctor probes lazy --- .dockerignore | 9 + .../bundled-channel-runtime-deps-docker.sh | 55 +++++- .../plugins/bundled.shape-guard.test.ts | 7 + src/channels/plugins/bundled.ts | 158 +++++++++++++----- .../shared/allowlist-policy-repair.test.ts | 36 ++++ .../doctor/shared/allowlist-policy-repair.ts | 6 + src/commands/doctor/shared/channel-doctor.ts | 29 +++- .../shared/empty-allowlist-scan.test.ts | 30 ++++ .../doctor/shared/empty-allowlist-scan.ts | 13 ++ src/infra/state-migrations.ts | 2 +- src/plugin-sdk/channel-entry-contract.ts | 14 +- 11 files changed, 304 insertions(+), 55 deletions(-) diff --git a/.dockerignore b/.dockerignore index 719a56d4c42..1a15f7dc629 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,12 @@ .bun .artifacts **/.artifacts +.local +**/.local +.pi +**/.pi +__openclaw_vitest__ +**/__openclaw_vitest__ .tmp **/.tmp .DS_Store @@ -40,6 +46,9 @@ docs/.generated *.log tmp **/tmp +dist-runtime +**/dist-runtime +openclaw-path-alias-* # build artifacts dist diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 9fca26308c5..e070d3d14d7 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -904,9 +904,56 @@ assert_dep_absent_everywhere() { exit 1 fi done - if find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$dep_path/package.json" -type f | grep -q .; then - echo "disabled $channel unexpectedly staged $dep_path externally" >&2 - find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true + + if ! node - <<'NODE' "$OPENCLAW_PLUGIN_STAGE_DIR" "$dep_path" +const fs = require("node:fs"); +const path = require("node:path"); + +const stageDir = process.argv[2]; +const depName = process.argv[3]; +const manifestName = ".openclaw-runtime-deps.json"; +const matches = []; + +function visit(dir) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (entry.name !== manifestName) { + continue; + } + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(fullPath, "utf8")); + } catch { + continue; + } + const specs = Array.isArray(parsed.specs) ? parsed.specs : []; + for (const spec of specs) { + if (typeof spec === "string" && spec.startsWith(`${depName}@`)) { + matches.push(`${fullPath}: ${spec}`); + } + } + } +} + +visit(stageDir); +if (matches.length > 0) { + process.stderr.write(`${matches.join("\n")}\n`); + process.exit(1); +} +NODE + then + echo "disabled $channel unexpectedly selected $dep_path for external runtime deps" >&2 + cat /tmp/openclaw-disabled-config-doctor.log >&2 exit 1 fi } @@ -969,7 +1016,7 @@ assert_dep_absent_everywhere telegram grammy "$root" assert_dep_absent_everywhere slack @slack/web-api "$root" assert_dep_absent_everywhere discord discord-api-types "$root" -if grep -Eq "\\[plugins\\] (telegram|slack|discord) installed bundled runtime deps:" /tmp/openclaw-disabled-config-doctor.log; then +if grep -Eq "(used by .*\\b(telegram|slack|discord)\\b|\\[plugins\\] (telegram|slack|discord) installed bundled runtime deps:)" /tmp/openclaw-disabled-config-doctor.log; then echo "doctor installed runtime deps for an explicitly disabled channel/plugin" >&2 cat /tmp/openclaw-disabled-config-doctor.log >&2 exit 1 diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 5a32e39005e..8823666bd72 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -522,6 +522,13 @@ describe("bundled channel entry shape guards", () => { "./bundled.js?scope=bundled-setup-only-feature", ); + expect( + bundled.listBundledChannelLegacyStateMigrationDetectors({ + config: { channels: { alpha: { enabled: false } } }, + }), + ).toEqual([]); + expect(testGlobal.__bundledSetupOnlySetupLoaded).toBeUndefined(); + const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors(); expect( detectors.map((detector) => diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 2fcfa545f02..933a5d0d9f6 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { @@ -17,6 +18,7 @@ import { } from "../../plugins/bundled-runtime-root.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; +import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; import { normalizeChannelMeta } from "./meta-normalization.js"; import { isJavaScriptModulePath, loadChannelPluginModule } from "./module-loader.js"; @@ -44,8 +46,12 @@ type BundledChannelSetupEntryRuntimeContract = { loadSetupSecrets?: ( options?: BundledEntryModuleLoadOptions, ) => ChannelPlugin["secrets"] | undefined; - loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; - loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; + loadLegacyStateMigrationDetector?: ( + options?: BundledEntryModuleLoadOptions, + ) => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: ( + options?: BundledEntryModuleLoadOptions, + ) => BundledChannelLegacySessionSurface; features?: { legacyStateMigrations?: boolean; legacySessionSurfaces?: boolean; @@ -347,15 +353,74 @@ function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } +function shouldIncludeBundledChannelSetupFeatureForConfig(params: { + metadata: BundledChannelPluginMetadata; + config?: OpenClawConfig; +}): boolean { + if (!params.config) { + return true; + } + const plugins = params.config.plugins; + if (plugins?.enabled === false) { + return false; + } + const pluginId = params.metadata.manifest.id; + if (plugins?.deny?.includes(pluginId)) { + return false; + } + if (plugins?.entries?.[pluginId]?.enabled === false) { + return false; + } + + let hasExplicitChannelDisable = false; + for (const channelId of params.metadata.manifest.channels ?? [pluginId]) { + const normalizedChannelId = normalizeOptionalLowercaseString(channelId); + if (!normalizedChannelId) { + continue; + } + const channelConfig = (params.config.channels as Record | undefined)?.[ + normalizedChannelId + ]; + if (!channelConfig || typeof channelConfig !== "object" || Array.isArray(channelConfig)) { + continue; + } + if ((channelConfig as { enabled?: unknown }).enabled === false) { + hasExplicitChannelDisable = true; + continue; + } + return true; + } + + return !hasExplicitChannelDisable; +} + function listBundledChannelPluginIdsForSetupFeature( rootScope: BundledChannelRootScope, feature: keyof NonNullable, + options: { config?: OpenClawConfig } = {}, ): readonly ChannelId[] { const hinted = listBundledChannelMetadata(rootScope) - .filter((metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true) + .filter( + (metadata) => + metadata.packageManifest?.setupFeatures?.[feature] === true && + shouldIncludeBundledChannelSetupFeatureForConfig({ + metadata, + config: options.config, + }), + ) .map((metadata) => metadata.manifest.id) .toSorted((left, right) => left.localeCompare(right)); - return hinted.length > 0 ? hinted : listBundledChannelPluginIdsForRoot(rootScope); + return hinted.length > 0 + ? hinted + : listBundledChannelMetadata(rootScope) + .filter((metadata) => + shouldIncludeBundledChannelSetupFeatureForConfig({ + metadata, + config: options.config, + }), + ) + .map((metadata) => metadata.manifest.id) + .toSorted((left, right) => left.localeCompare(right)); } export function listBundledChannelPluginIds(): readonly ChannelId[] { @@ -626,9 +691,12 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, + options: { config?: OpenClawConfig } = {}, ): readonly ChannelPlugin[] { const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, feature).flatMap((id) => { + return listBundledChannelPluginIdsForSetupFeature(rootScope, feature, { + config: options.config, + }).flatMap((id) => { const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!hasSetupEntryFeature(setupEntry, feature)) { return []; @@ -638,50 +706,50 @@ export function listBundledChannelSetupPluginsByFeature( }); } -export function listBundledChannelLegacySessionSurfaces(): readonly BundledChannelLegacySessionSurface[] { +export function listBundledChannelLegacySessionSurfaces( + options: { + config?: OpenClawConfig; + } = {}, +): readonly BundledChannelLegacySessionSurface[] { const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces").flatMap( - (id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( - id, - rootScope, - cacheContext, - ); - const surface = setupEntry?.loadLegacySessionSurface?.(); - if (surface) { - return [surface]; - } - if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { - return []; - } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); - return plugin?.messaging ? [plugin.messaging] : []; - }, - ); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces", { + config: options.config, + }).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); + const surface = setupEntry?.loadLegacySessionSurface?.({ installRuntimeDeps: false }); + if (surface) { + return [surface]; + } + if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.messaging ? [plugin.messaging] : []; + }); } -export function listBundledChannelLegacyStateMigrationDetectors(): readonly BundledChannelLegacyStateMigrationDetector[] { +export function listBundledChannelLegacyStateMigrationDetectors( + options: { + config?: OpenClawConfig; + } = {}, +): readonly BundledChannelLegacyStateMigrationDetector[] { const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations").flatMap( - (id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( - id, - rootScope, - cacheContext, - ); - const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); - if (detector) { - return [detector]; - } - if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { - return []; - } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); - return plugin?.lifecycle?.detectLegacyStateMigrations - ? [plugin.lifecycle.detectLegacyStateMigrations] - : []; - }, - ); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations", { + config: options.config, + }).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); + const detector = setupEntry?.loadLegacyStateMigrationDetector?.({ installRuntimeDeps: false }); + if (detector) { + return [detector]; + } + if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.lifecycle?.detectLegacyStateMigrations + ? [plugin.lifecycle.detectLegacyStateMigrations] + : []; + }); } export function hasBundledChannelEntryFeature( diff --git a/src/commands/doctor/shared/allowlist-policy-repair.test.ts b/src/commands/doctor/shared/allowlist-policy-repair.test.ts index f416d9f126a..d4c6dc0c387 100644 --- a/src/commands/doctor/shared/allowlist-policy-repair.test.ts +++ b/src/commands/doctor/shared/allowlist-policy-repair.test.ts @@ -33,4 +33,40 @@ describe("doctor allowlist-policy repair", () => { expect(result.config.channels?.matrix?.allowFrom).toBeUndefined(); expect(result.config.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org"]); }); + + it("skips disabled channel and account entries", async () => { + readChannelAllowFromStoreMock.mockResolvedValue(["alice"]); + + const result = await maybeRepairAllowlistPolicyAllowFrom({ + channels: { + telegram: { + enabled: false, + dmPolicy: "allowlist", + }, + signal: { + accounts: { + disabled: { enabled: false, dmPolicy: "allowlist" }, + }, + }, + }, + }); + + expect(result).toEqual({ + config: { + channels: { + telegram: { + enabled: false, + dmPolicy: "allowlist", + }, + signal: { + accounts: { + disabled: { enabled: false, dmPolicy: "allowlist" }, + }, + }, + }, + }, + changes: [], + }); + expect(readChannelAllowFromStoreMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/doctor/shared/allowlist-policy-repair.ts b/src/commands/doctor/shared/allowlist-policy-repair.ts index e0251e98579..e724081aa6a 100644 --- a/src/commands/doctor/shared/allowlist-policy-repair.ts +++ b/src/commands/doctor/shared/allowlist-policy-repair.ts @@ -118,6 +118,9 @@ export async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig): if (!channelConfig || typeof channelConfig !== "object") { continue; } + if (channelConfig.enabled === false) { + continue; + } await recoverAllowFromForAccount({ channelName, account: channelConfig, @@ -132,6 +135,9 @@ export async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig): if (!accountConfig || typeof accountConfig !== "object") { continue; } + if ((accountConfig as { enabled?: unknown }).enabled === false) { + continue; + } await recoverAllowFromForAccount({ channelName, account: accountConfig as Record, diff --git a/src/commands/doctor/shared/channel-doctor.ts b/src/commands/doctor/shared/channel-doctor.ts index 75e6ff02423..adf206b1cde 100644 --- a/src/commands/doctor/shared/channel-doctor.ts +++ b/src/commands/doctor/shared/channel-doctor.ts @@ -11,6 +11,7 @@ import type { ChannelDoctorSequenceResult, } from "../../../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; type ChannelDoctorEntry = { doctor: ChannelDoctorAdapter; @@ -59,6 +60,9 @@ export type ChannelDoctorEmptyAllowlistPolicyHooks = { }; function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] { + if (cfg.plugins?.enabled === false) { + return []; + } const channels = cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels) ? cfg.channels @@ -72,6 +76,9 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] { if (channelId === "defaults") { return false; } + if (isChannelDoctorBlockedByConfig(channelId, cfg)) { + return false; + } const entry = channelEntries[channelId]; return ( !entry || @@ -83,6 +90,21 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] { .toSorted(); } +function isChannelDoctorBlockedByConfig(channelId: string, cfg: OpenClawConfig): boolean { + if (cfg.plugins?.enabled === false) { + return true; + } + const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? channelId; + if (cfg.plugins?.entries?.[normalizedChannelId]?.enabled === false) { + return true; + } + const channelEntry = (cfg.channels as Record | undefined)?.[normalizedChannelId]; + return ( + Boolean(channelEntry && typeof channelEntry === "object" && !Array.isArray(channelEntry)) && + (channelEntry as { enabled?: unknown }).enabled === false + ); +} + function safeGetLoadedChannelPlugin(id: string) { try { return getLoadedChannelPlugin(id); @@ -180,7 +202,12 @@ function listChannelDoctorEntries( if (channelIds.length === 0) { return []; } - const selectedIds = new Set(channelIds); + const selectedIds = new Set( + channelIds.filter((id) => !isChannelDoctorBlockedByConfig(id, context.cfg)), + ); + if (selectedIds.size === 0) { + return []; + } const readOnlyPluginsById = options.readOnlyPluginsById ?? listReadOnlyChannelPluginsById(context); diff --git a/src/commands/doctor/shared/empty-allowlist-scan.test.ts b/src/commands/doctor/shared/empty-allowlist-scan.test.ts index 88d60a8e002..10a2f59e596 100644 --- a/src/commands/doctor/shared/empty-allowlist-scan.test.ts +++ b/src/commands/doctor/shared/empty-allowlist-scan.test.ts @@ -56,4 +56,34 @@ describe("doctor empty allowlist policy scan", () => { expect(warnings).toContain("extra:channels.telegram"); }); + + it("skips disabled channel and account entries", () => { + const extraWarningsForAccount = vi.fn(({ prefix }) => [`extra:${prefix}`]); + + const warnings = scanEmptyAllowlistPolicyWarnings( + { + channels: { + telegram: { + enabled: false, + dmPolicy: "allowlist", + accounts: { + default: { dmPolicy: "allowlist" }, + }, + }, + signal: { + accounts: { + disabled: { enabled: false, dmPolicy: "allowlist" }, + }, + }, + }, + }, + { doctorFixCommand: "openclaw doctor --fix", extraWarningsForAccount }, + ); + + expect(warnings).toEqual(["extra:channels.signal"]); + expect(extraWarningsForAccount).toHaveBeenCalledTimes(1); + expect(extraWarningsForAccount).toHaveBeenCalledWith( + expect.objectContaining({ prefix: "channels.signal" }), + ); + }); }); diff --git a/src/commands/doctor/shared/empty-allowlist-scan.ts b/src/commands/doctor/shared/empty-allowlist-scan.ts index 1c71d106333..5ce32861339 100644 --- a/src/commands/doctor/shared/empty-allowlist-scan.ts +++ b/src/commands/doctor/shared/empty-allowlist-scan.ts @@ -12,6 +12,13 @@ type ScanEmptyAllowlistPolicyWarningsParams = { ) => boolean; }; +function isDisabledRecord(value: unknown): boolean { + return ( + Boolean(value && typeof value === "object" && !Array.isArray(value)) && + (value as { enabled?: unknown }).enabled === false + ); +} + export function scanEmptyAllowlistPolicyWarnings( cfg: OpenClawConfig, params: ScanEmptyAllowlistPolicyWarningsParams, @@ -76,6 +83,9 @@ export function scanEmptyAllowlistPolicyWarnings( if (!channelConfig || typeof channelConfig !== "object") { continue; } + if (isDisabledRecord(channelConfig)) { + continue; + } checkAccount(channelConfig, `channels.${channelName}`, channelName); const accounts = asObjectRecord(channelConfig.accounts); @@ -86,6 +96,9 @@ export function scanEmptyAllowlistPolicyWarnings( if (!account || typeof account !== "object") { continue; } + if (isDisabledRecord(account)) { + continue; + } checkAccount( account as DoctorAccountRecord, `channels.${channelName}.accounts.${accountId}`, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index 579c2bb1dc7..346a9329428 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -669,7 +669,7 @@ async function collectChannelLegacyStateMigrationPlans(params: { const plans: ChannelLegacyStateMigrationPlan[] = []; // Legacy state detection belongs on a narrow setup-entry surface so doctor // does not cold-load unrelated runtime channel code. - const detectors = listBundledChannelLegacyStateMigrationDetectors(); + const detectors = listBundledChannelLegacyStateMigrationDetectors({ config: params.cfg }); for (const detectLegacyStateMigrations of detectors) { const detected = await detectLegacyStateMigrations({ cfg: params.cfg, diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index ef89ff7fcef..5f77bd4d9d9 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -111,8 +111,12 @@ export type BundledChannelSetupEntryContract = { loadSetupSecrets?: ( options?: BundledEntryModuleLoadOptions, ) => ChannelPlugin["secrets"] | undefined; - loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; - loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; + loadLegacyStateMigrationDetector?: ( + options?: BundledEntryModuleLoadOptions, + ) => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: ( + options?: BundledEntryModuleLoadOptions, + ) => BundledChannelLegacySessionSurface; setChannelRuntime?: (runtime: PluginRuntime) => void; features?: BundledChannelSetupEntryFeatures; }; @@ -519,17 +523,19 @@ export function defineBundledChannelSetupEntry({ } : undefined; const loadLegacyStateMigrationDetector = legacyStateMigrations - ? () => + ? (options?: BundledEntryModuleLoadOptions) => loadBundledEntryExportSync( importMetaUrl, legacyStateMigrations, + options, ) : undefined; const loadLegacySessionSurface = legacySessionSurface - ? () => + ? (options?: BundledEntryModuleLoadOptions) => loadBundledEntryExportSync( importMetaUrl, legacySessionSurface, + options, ) : undefined; return {