From eed7b13b62d6d46123a95790145fe6f39682ab17 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 18:17:39 -0700 Subject: [PATCH] fix(doctor): scope bundled runtime deps to active plugins --- src/cli/plugins-cli.list.test.ts | 2 +- src/cli/plugins-cli.ts | 2 +- ...doctor-bundled-plugin-runtime-deps.test.ts | 156 +++++++++++++++- .../doctor-bundled-plugin-runtime-deps.ts | 16 +- src/plugins/bundled-runtime-deps.ts | 20 +- src/plugins/effective-plugin-ids.ts | 171 ++++++++++++++++++ src/plugins/status.ts | 12 ++ 7 files changed, 366 insertions(+), 13 deletions(-) create mode 100644 src/plugins/effective-plugin-ids.ts diff --git a/src/cli/plugins-cli.list.test.ts b/src/cli/plugins-cli.list.test.ts index b2f67bf2235..07ffd5d5a13 100644 --- a/src/cli/plugins-cli.list.test.ts +++ b/src/cli/plugins-cli.list.test.ts @@ -71,7 +71,7 @@ describe("plugins cli list", () => { await runPluginsCommand(["plugins", "doctor"]); - expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith(); + expect(buildPluginDiagnosticsReport).toHaveBeenCalledWith({ effectiveOnly: true }); expect(runtimeLogs).toContain("No plugin issues detected."); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 7e92b6a7213..0387ce3f923 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -836,7 +836,7 @@ export function registerPluginsCli(program: Command) { buildPluginDiagnosticsReport, formatPluginCompatibilityNotice, } = await import("../plugins/status.js"); - const report = buildPluginDiagnosticsReport(); + const report = buildPluginDiagnosticsReport({ effectiveOnly: true }); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); const compatibility = buildPluginCompatibilityNotices({ report }); diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts index 006b283005a..39e1a381705 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.test.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.test.ts @@ -18,12 +18,21 @@ function writeJson(filePath: string, value: unknown) { } function writeBundledChannelPlugin(root: string, id: string, dependencies: Record) { + writeBundledChannelOwnerPlugin(root, id, [id], dependencies); +} + +function writeBundledChannelOwnerPlugin( + root: string, + id: string, + channels: string[], + dependencies: Record, +) { writeJson(path.join(root, "dist", "extensions", id, "package.json"), { dependencies, }); writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), { id, - channels: [id], + channels, configSchema: { type: "object" }, }); } @@ -259,16 +268,16 @@ describe("doctor bundled plugin runtime deps", () => { expect(result.conflicts).toEqual([]); }); - it("reports default-enabled bundled plugin deps", () => { + it("reports default-enabled gateway startup sidecar deps", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); - writeJson(path.join(root, "dist", "extensions", "openai", "package.json"), { + writeJson(path.join(root, "dist", "extensions", "browser", "package.json"), { dependencies: { - "openai-only": "1.0.0", + "browser-only": "1.0.0", }, }); - writeJson(path.join(root, "dist", "extensions", "openai", "openclaw.plugin.json"), { - id: "openai", + writeJson(path.join(root, "dist", "extensions", "browser", "openclaw.plugin.json"), { + id: "browser", enabledByDefault: true, configSchema: { type: "object" }, }); @@ -281,7 +290,39 @@ describe("doctor bundled plugin runtime deps", () => { }); expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ - "openai-only@1.0.0", + "browser-only@1.0.0", + ]); + expect(result.conflicts).toEqual([]); + }); + + it("reports explicitly enabled provider deps", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeJson(path.join(root, "dist", "extensions", "bedrock", "package.json"), { + dependencies: { + "bedrock-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), { + id: "bedrock", + enabledByDefault: true, + providers: ["bedrock"], + configSchema: { type: "object" }, + }); + + const result = scanBundledPluginRuntimeDeps({ + packageRoot: root, + config: { + plugins: { + enabled: true, + allow: ["bedrock"], + entries: { bedrock: { enabled: true } }, + }, + }, + }); + + expect(result.missing.map((dep) => `${dep.name}@${dep.version}`)).toEqual([ + "bedrock-only@1.0.0", ]); expect(result.conflicts).toEqual([]); }); @@ -352,6 +393,78 @@ describe("doctor bundled plugin runtime deps", () => { expect(result.conflicts).toEqual([]); }); + it("does not repair inactive default-enabled provider deps", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeJson(path.join(root, "dist", "extensions", "bedrock", "package.json"), { + dependencies: { + "bedrock-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), { + id: "bedrock", + enabledByDefault: true, + providers: ["bedrock"], + configSchema: { type: "object" }, + }); + const installed = createInstalledRuntimeDeps(); + + await maybeRepairBundledPluginRuntimeDeps({ + runtime: { error: () => {} } as never, + prompter: createNonInteractivePrompter(), + packageRoot: root, + config: { + plugins: { enabled: true }, + }, + installDeps: (params) => { + installed.push(params); + }, + }); + + expect(installed).toEqual([]); + }); + + it("repairs explicitly enabled provider deps", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeJson(path.join(root, "dist", "extensions", "bedrock", "package.json"), { + dependencies: { + "bedrock-only": "1.0.0", + }, + }); + writeJson(path.join(root, "dist", "extensions", "bedrock", "openclaw.plugin.json"), { + id: "bedrock", + enabledByDefault: true, + providers: ["bedrock"], + configSchema: { type: "object" }, + }); + const installed = createInstalledRuntimeDeps(); + + await maybeRepairBundledPluginRuntimeDeps({ + runtime: { error: () => {} } as never, + prompter: createNonInteractivePrompter(), + packageRoot: root, + config: { + plugins: { + enabled: true, + allow: ["bedrock"], + entries: { bedrock: { enabled: true } }, + }, + }, + installDeps: (params) => { + installed.push(params); + }, + }); + + expect(installed).toEqual([ + { + installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root), + missingSpecs: ["bedrock-only@1.0.0"], + installSpecs: ["bedrock-only@1.0.0"], + }, + ]); + }); + it("repairs missing deps during non-interactive doctor", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); writeJson(path.join(root, "package.json"), { name: "openclaw" }); @@ -383,6 +496,35 @@ describe("doctor bundled plugin runtime deps", () => { expect(readRetainedRuntimeDepsManifest(installRoot)).toEqual(["grammy@1.37.0"]); }); + it("repairs deps for configured channel owner plugins", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); + writeJson(path.join(root, "package.json"), { name: "openclaw" }); + writeBundledChannelOwnerPlugin(root, "chat-bridge", ["telegram"], { grammy: "1.37.0" }); + const installed = createInstalledRuntimeDeps(); + + await maybeRepairBundledPluginRuntimeDeps({ + runtime: { error: () => {} } as never, + prompter: createNonInteractivePrompter(), + packageRoot: root, + config: { + plugins: { enabled: true }, + channels: { telegram: { enabled: true } }, + }, + installDeps: (params) => { + installed.push(params); + }, + }); + + const installRoot = resolveBundledRuntimeDependencyPackageInstallRoot(root); + expect(installed).toEqual([ + { + installRoot, + missingSpecs: ["grammy@1.37.0"], + installSpecs: ["grammy@1.37.0"], + }, + ]); + }); + it("throws when bundled runtime dependency repair fails", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-")); const errors: string[] = []; diff --git a/src/commands/doctor-bundled-plugin-runtime-deps.ts b/src/commands/doctor-bundled-plugin-runtime-deps.ts index e572eb17c8c..4918f22011c 100644 --- a/src/commands/doctor-bundled-plugin-runtime-deps.ts +++ b/src/commands/doctor-bundled-plugin-runtime-deps.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; @@ -7,6 +8,7 @@ import { scanBundledPluginRuntimeDeps, type BundledRuntimeDepsInstallParams, } from "../plugins/bundled-runtime-deps.js"; +import { resolveEffectivePluginIds } from "../plugins/effective-plugin-ids.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -31,11 +33,23 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: { return; } + const env = params.env ?? process.env; + const bundledPluginsDir = path.join(packageRoot, "dist", "extensions"); + const effectivePluginIds = params.config + ? resolveEffectivePluginIds({ + config: params.config, + env: { + ...env, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir, + }, + }) + : undefined; const { deps, missing, conflicts } = scanBundledPluginRuntimeDeps({ packageRoot, config: params.config, + pluginIds: effectivePluginIds, includeConfiguredChannels: params.includeConfiguredChannels, - env: params.env ?? process.env, + env, }); if (conflicts.length > 0) { const conflictLines = conflicts.flatMap((conflict) => diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 2e6c69adcd0..4e248ef3602 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -930,9 +930,9 @@ function isBundledPluginConfiguredForRuntimeDeps(params: { if (entry?.enabled === false) { return false; } + const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache); let hasExplicitChannelDisable = false; let hasConfiguredChannel = false; - const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache); for (const channelId of manifest.channels) { const normalizedChannelId = normalizeOptionalLowercaseString(channelId); if (!normalizedChannelId) { @@ -990,12 +990,26 @@ function shouldIncludeBundledPluginRuntimeDeps(params: { includeConfiguredChannels?: boolean; manifestCache?: BundledPluginRuntimeDepsManifestCache; }): boolean { - if (params.pluginIds && !params.pluginIds.has(params.pluginId)) { - return false; + const scopedToPluginIds = Boolean(params.pluginIds); + if (params.pluginIds) { + if (!params.pluginIds.has(params.pluginId)) { + return false; + } + if (!params.config) { + return true; + } } if (!params.config) { return true; } + if (scopedToPluginIds) { + const plugins = normalizePluginsConfig(params.config.plugins); + if (!plugins.enabled || plugins.deny.includes(params.pluginId)) { + return false; + } + const entry = plugins.entries[params.pluginId]; + return entry?.enabled !== false; + } return isBundledPluginConfiguredForRuntimeDeps({ config: params.config, pluginId: params.pluginId, diff --git a/src/plugins/effective-plugin-ids.ts b/src/plugins/effective-plugin-ids.ts new file mode 100644 index 00000000000..f151134cfe9 --- /dev/null +++ b/src/plugins/effective-plugin-ids.ts @@ -0,0 +1,171 @@ +import fs from "node:fs"; +import path from "node:path"; +import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js"; +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { resolveBundledPluginsDir } from "./bundled-dir.js"; +import { + listExplicitConfiguredChannelIdsForConfig, + resolveConfiguredChannelPluginIds, + resolveGatewayStartupPluginIds, +} from "./channel-plugin-ids.js"; +import { normalizePluginsConfig } from "./config-state.js"; +import { loadPluginManifest } from "./manifest.js"; + +function listExplicitlyDisabledChannelIds(config: OpenClawConfig): Set { + const channels = config.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return new Set(); + } + return new Set( + Object.entries(channels) + .filter(([, value]) => { + return ( + value && + typeof value === "object" && + !Array.isArray(value) && + (value as { enabled?: unknown }).enabled === false + ); + }) + .map(([channelId]) => normalizeOptionalLowercaseString(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)), + ); +} + +function collectConfiguredChannelIds( + config: OpenClawConfig, + activationSourceConfig: OpenClawConfig, + env: NodeJS.ProcessEnv, +): string[] { + const disabled = new Set([ + ...listExplicitlyDisabledChannelIds(config), + ...listExplicitlyDisabledChannelIds(activationSourceConfig), + ]); + const ids = new Set([ + ...listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false }), + ...listExplicitConfiguredChannelIdsForConfig(activationSourceConfig), + ]); + return [...ids] + .map((channelId) => normalizeOptionalLowercaseString(channelId)) + .filter((channelId): channelId is string => { + if (!channelId) { + return false; + } + return !disabled.has(channelId); + }) + .toSorted((left, right) => left.localeCompare(right)); +} + +function collectBundledChannelOwnerPluginIds(params: { + channelIds: readonly string[]; + env: NodeJS.ProcessEnv; +}): string[] { + const channelIds = new Set( + params.channelIds + .map((channelId) => normalizeOptionalLowercaseString(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)), + ); + if (channelIds.size === 0) { + return []; + } + const bundledDir = resolveBundledPluginsDir(params.env); + if (!bundledDir) { + return []; + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(bundledDir, { withFileTypes: true }); + } catch { + return []; + } + const pluginIds = new Set(); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const pluginDir = path.join(bundledDir, entry.name); + const manifest = loadPluginManifest(pluginDir, false); + if (!manifest.ok) { + continue; + } + if ( + (manifest.manifest.channels ?? []).some((channelId) => + channelIds.has(normalizeOptionalLowercaseString(channelId) ?? ""), + ) + ) { + const pluginId = normalizeOptionalLowercaseString(manifest.manifest.id); + if (pluginId) { + pluginIds.add(pluginId); + } + } + } + return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); +} + +function collectExplicitEffectivePluginIds(config: OpenClawConfig): string[] { + const plugins = normalizePluginsConfig(config.plugins); + if (!plugins.enabled) { + return []; + } + + const ids = new Set(plugins.allow); + for (const [pluginId, entry] of Object.entries(plugins.entries)) { + if ( + entry?.enabled === true && + (plugins.allow.length === 0 || plugins.allow.includes(pluginId)) + ) { + ids.add(pluginId); + } + } + for (const pluginId of plugins.deny) { + ids.delete(pluginId); + } + for (const [pluginId, entry] of Object.entries(plugins.entries)) { + if (entry?.enabled === false) { + ids.delete(pluginId); + } + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +export function resolveEffectivePluginIds(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + workspaceDir?: string; +}): string[] { + const autoEnabled = applyPluginAutoEnable({ + config: params.config, + env: params.env, + }); + const effectiveConfig = autoEnabled.config; + const ids = new Set(collectExplicitEffectivePluginIds(effectiveConfig)); + const configuredChannelIds = collectConfiguredChannelIds( + effectiveConfig, + params.config, + params.env, + ); + for (const pluginId of resolveConfiguredChannelPluginIds({ + config: effectiveConfig, + activationSourceConfig: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + })) { + ids.add(pluginId); + } + for (const pluginId of collectBundledChannelOwnerPluginIds({ + channelIds: configuredChannelIds, + env: params.env, + })) { + ids.add(pluginId); + } + for (const pluginId of resolveGatewayStartupPluginIds({ + config: effectiveConfig, + activationSourceConfig: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + })) { + ids.add(pluginId); + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 423ef68c1f7..848622b90f7 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -12,6 +12,7 @@ import { } from "./bundled-compat.js"; import type { PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig } from "./config-state.js"; +import { resolveEffectivePluginIds } from "./effective-plugin-ids.js"; import { buildPluginShapeSummary, type PluginCapabilityEntry, @@ -149,6 +150,7 @@ function resolveReportedPluginVersion( type PluginReportParams = { config?: OpenClawConfig; + effectiveOnly?: boolean; workspaceDir?: string; /** Use an explicit env when plugin roots should resolve independently from process.env. */ env?: NodeJS.ProcessEnv; @@ -273,6 +275,14 @@ function buildPluginReport( config: effectiveConfig, pluginIds: bundledProviderIds, }); + const onlyPluginIds = + params?.effectiveOnly === true + ? resolveEffectivePluginIds({ + config: rawConfig, + workspaceDir, + env: params?.env ?? process.env, + }) + : undefined; const registry = loadModules ? loadOpenClawPlugins( @@ -284,6 +294,7 @@ function buildPluginReport( loadModules, activate: false, cache: false, + onlyPluginIds, }), ) : loadPluginMetadataRegistrySnapshot({ @@ -293,6 +304,7 @@ function buildPluginReport( env: params?.env, logger: params?.logger, loadModules: false, + onlyPluginIds, }); const importedPluginIds = new Set([ ...(loadModules