diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f165133033..0c94472e700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/registry: preserve explicit disabled plugin records during registry migration without persisting every unused bundled plugin discovered on disk. Thanks @shakkernerd. - Windows/native: keep CLI startup and bundled provider plugin loading off Windows ESM raw-path failure paths, fixing native onboarding/install smoke on Node 24. Thanks @steipete. diff --git a/src/commands/doctor/shared/plugin-registry-migration.test.ts b/src/commands/doctor/shared/plugin-registry-migration.test.ts index 7c972116bd8..3511a025650 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.test.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.test.ts @@ -38,7 +38,11 @@ function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { }; } -function createCandidate(rootDir: string, id = "demo"): PluginCandidate { +function createCandidate( + rootDir: string, + id = "demo", + origin: PluginCandidate["origin"] = "global", +): PluginCandidate { fs.writeFileSync( path.join(rootDir, "index.ts"), "throw new Error('runtime entry should not load while migrating plugin registry');\n", @@ -58,7 +62,7 @@ function createCandidate(rootDir: string, id = "demo"): PluginCandidate { idHint: id, source: path.join(rootDir, "index.ts"), rootDir, - origin: "global", + origin, }; } @@ -127,19 +131,22 @@ describe("plugin registry install migration", () => { }); }); - it("persists only plugins enabled by the central config policy", async () => { + it("persists migration-relevant plugin records without dropping explicit disabled state", async () => { const stateDir = makeTempDir(); const enabledDir = path.join(stateDir, "plugins", "enabled-demo"); const disabledDir = path.join(stateDir, "plugins", "disabled-demo"); + const unusedBundledDir = path.join(stateDir, "plugins", "unused-bundled"); fs.mkdirSync(enabledDir, { recursive: true }); fs.mkdirSync(disabledDir, { recursive: true }); + fs.mkdirSync(unusedBundledDir, { recursive: true }); await expect( migratePluginRegistryForInstall({ stateDir, candidates: [ createCandidate(enabledDir, "enabled-demo"), - createCandidate(disabledDir, "disabled-demo"), + createCandidate(disabledDir, "disabled-demo", "bundled"), + createCandidate(unusedBundledDir, "unused-bundled", "bundled"), ], readConfig: async () => ({ plugins: { @@ -155,15 +162,24 @@ describe("plugin registry install migration", () => { ).resolves.toMatchObject({ status: "migrated", current: { - plugins: [expect.objectContaining({ pluginId: "enabled-demo" })], + plugins: [ + expect.objectContaining({ pluginId: "enabled-demo", enabled: true }), + expect.objectContaining({ pluginId: "disabled-demo", enabled: false }), + ], }, }); await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({ - plugins: [expect.objectContaining({ pluginId: "enabled-demo" })], + plugins: [ + expect.objectContaining({ pluginId: "enabled-demo", enabled: true }), + expect.objectContaining({ pluginId: "disabled-demo", enabled: false }), + ], }); const persisted = await readPersistedInstalledPluginIndex({ stateDir }); - expect(persisted?.plugins.map((plugin) => plugin.pluginId)).toEqual(["enabled-demo"]); + expect(persisted?.plugins.map((plugin) => plugin.pluginId)).toEqual([ + "enabled-demo", + "disabled-demo", + ]); }); it("supports dry-run preflight without reading config or writing the registry", async () => { diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index 1bae6d94385..8feb62a6713 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { normalizeProviderId } from "../../../agents/provider-id.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { loadPluginInstallRecords, @@ -13,9 +14,9 @@ import { type InstalledPluginIndexStoreOptions, } from "../../../plugins/installed-plugin-index-store.js"; import { - listEnabledInstalledPluginRecords, loadInstalledPluginIndex, type InstalledPluginIndex, + type InstalledPluginIndexRecord, type LoadInstalledPluginIndexParams, } from "../../../plugins/installed-plugin-index.js"; @@ -111,6 +112,134 @@ async function readMigrationConfig( return await configModule.readBestEffortConfig(); } +function normalizeRegistryReference(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : undefined; +} + +function createMigrationPluginIdNormalizer( + index: InstalledPluginIndex, +): (pluginId: string) => string { + const aliases = new Map(); + for (const plugin of index.plugins) { + const pluginId = normalizeRegistryReference(plugin.pluginId); + if (!pluginId) { + continue; + } + aliases.set(pluginId, plugin.pluginId); + for (const alias of [ + ...plugin.contributions.providers, + ...plugin.contributions.channels, + ...plugin.contributions.setupProviders, + ...plugin.contributions.cliBackends, + ...plugin.contributions.modelCatalogProviders, + ]) { + const normalizedAlias = normalizeRegistryReference(alias); + if (normalizedAlias && !aliases.has(normalizedAlias)) { + aliases.set(normalizedAlias, plugin.pluginId); + } + } + } + return (pluginId: string) => { + const normalized = normalizeRegistryReference(pluginId); + return normalized ? (aliases.get(normalized) ?? pluginId.trim()) : pluginId.trim(); + }; +} + +function addPluginReference( + references: Set, + normalizePluginId: (pluginId: string) => string, + value: unknown, +): void { + if (typeof value !== "string") { + return; + } + const normalized = normalizePluginId(value); + if (normalized) { + references.add(normalized); + } +} + +function listConfiguredChannelIds(config: OpenClawConfig): Set { + const channels = config.channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return new Set(); + } + return new Set( + Object.keys(channels) + .map((channelId) => normalizeRegistryReference(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)), + ); +} + +function listConfiguredModelProviderIds(config: OpenClawConfig): Set { + const providers = config.models?.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) { + return new Set(); + } + return new Set( + Object.keys(providers) + .map((providerId) => normalizeProviderId(providerId)) + .filter(Boolean), + ); +} + +export function listMigrationRelevantPluginRecords(params: { + index: InstalledPluginIndex; + config: OpenClawConfig; + installRecords: Record; +}): readonly InstalledPluginIndexRecord[] { + const normalizePluginId = createMigrationPluginIdNormalizer(params.index); + const referencedPluginIds = new Set(); + const installedPluginIds = new Set(); + + for (const pluginId of Object.keys(params.installRecords)) { + addPluginReference(installedPluginIds, normalizePluginId, pluginId); + } + + const plugins = params.config.plugins; + for (const pluginId of plugins?.allow ?? []) { + addPluginReference(referencedPluginIds, normalizePluginId, pluginId); + } + for (const pluginId of plugins?.deny ?? []) { + addPluginReference(referencedPluginIds, normalizePluginId, pluginId); + } + for (const pluginId of Object.keys(plugins?.entries ?? {})) { + addPluginReference(referencedPluginIds, normalizePluginId, pluginId); + } + for (const pluginId of Object.values(plugins?.slots ?? {})) { + if (normalizeRegistryReference(pluginId) === "none") { + continue; + } + addPluginReference(referencedPluginIds, normalizePluginId, pluginId); + } + + const configuredChannelIds = listConfiguredChannelIds(params.config); + const configuredModelProviderIds = listConfiguredModelProviderIds(params.config); + + return params.index.plugins.filter((plugin) => { + if (plugin.origin !== "bundled") { + return true; + } + if (installedPluginIds.has(plugin.pluginId) || referencedPluginIds.has(plugin.pluginId)) { + return true; + } + if ( + plugin.contributions.channels.some((channelId) => + configuredChannelIds.has(normalizeRegistryReference(channelId) ?? ""), + ) + ) { + return true; + } + return plugin.contributions.providers.some((providerId) => + configuredModelProviderIds.has(normalizeProviderId(providerId)), + ); + }); +} + export async function migratePluginRegistryForInstall( params: PluginRegistryInstallMigrationParams = {}, ): Promise { @@ -139,7 +268,11 @@ export async function migratePluginRegistryForInstall( const current: InstalledPluginIndex = { ...candidateIndex, refreshReason: "migration", - plugins: listEnabledInstalledPluginRecords(candidateIndex, config), + plugins: listMigrationRelevantPluginRecords({ + index: candidateIndex, + config, + installRecords, + }), }; if (Object.keys(installRecords).length > 0) { await writePersistedPluginInstallLedger(installRecords, params);