diff --git a/CHANGELOG.md b/CHANGELOG.md index 21d63c78025..61391b2f56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes - CLI/plugins: stop treating the non-plugin `auth` command root as a bundled plugin id, so restrictive `plugins.allow` configs no longer tell users to add stale `auth` plugin entries. +- Doctor/plugins: update configured plugin installs whose stale manifests still declare channels without `channelConfigs`, so beta upgrades repair old Discord-style package payloads during `doctor --fix`. - Plugins/externalization: repair missing configured plugin installs from npm by default, reserve ClawHub downloads for explicit `clawhubSpec` metadata, and cover agent-runtime/env-selected plugin repair. Thanks @vincentkoc. - Upgrade/config: validate configured web-search providers and statically suppressed model/provider pairs against the active plugin set at config load, so stale plugin state fails loud before runtime fallback. - Status/update: resolve beta update-channel checks from the installed version when config still says `stable`, and let `status --deep` reuse live gateway channel credential state instead of warning on command-path-only token misses. diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index dad5e66d82a..0aca9579d10 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -791,6 +791,101 @@ describe("repairMissingConfiguredPluginInstalls", () => { expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']); }); + it("updates a configured plugin when its installed manifest lacks channel config descriptors", async () => { + const records = { + discord: { + source: "npm", + spec: "@openclaw/discord", + installPath: "/tmp/openclaw-plugins/discord", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records); + mocks.listChannelPluginCatalogEntries.mockReturnValue([ + { + id: "discord", + pluginId: "discord", + meta: { label: "Discord" }, + install: { + npmSpec: "@openclaw/discord", + }, + }, + ]); + mocks.loadPluginMetadataSnapshot.mockReturnValue({ + plugins: [ + { + id: "discord", + channels: ["discord"], + }, + ], + diagnostics: [ + { + level: "warn", + pluginId: "discord", + message: + "channel plugin manifest declares discord without channelConfigs metadata; add openclaw.plugin.json#channelConfigs so config schema and setup surfaces work before runtime loads", + }, + ], + }); + mocks.updateNpmInstalledPlugins.mockResolvedValue({ + changed: true, + config: { + plugins: { + installs: { + discord: { + source: "npm", + spec: "@openclaw/discord", + installPath: "/tmp/openclaw-plugins/discord", + }, + }, + }, + }, + outcomes: [ + { + pluginId: "discord", + status: "updated", + message: "Updated discord.", + }, + ], + }); + + const { repairMissingConfiguredPluginInstalls } = + await import("./missing-configured-plugin-install.js"); + const result = await repairMissingConfiguredPluginInstalls({ + cfg: { + update: { channel: "beta" }, + plugins: { + entries: { + discord: { enabled: true }, + }, + }, + channels: { + discord: { enabled: true }, + }, + }, + env: {}, + }); + + expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginIds: ["discord"], + updateChannel: "beta", + config: expect.objectContaining({ + plugins: expect.objectContaining({ installs: records }), + }), + }), + ); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith( + expect.objectContaining({ + discord: expect.objectContaining({ installPath: "/tmp/openclaw-plugins/discord" }), + }), + { env: {} }, + ); + expect(result).toEqual({ + changes: ['Repaired missing configured plugin "discord".'], + warnings: [], + }); + }); + it("reinstalls a recorded external web search plugin from provider-only config", async () => { const records = { brave: { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index d18e26a3626..7fd9a9b143d 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -20,6 +20,7 @@ import { resolveOfficialExternalPluginInstall, resolveOfficialExternalPluginLabel, } from "../../../plugins/official-external-plugin-catalog.js"; +import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; @@ -48,6 +49,8 @@ const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[] }, ]; +const MISSING_CHANNEL_CONFIG_DESCRIPTOR_DIAGNOSTIC = "without channelConfigs metadata"; + function shouldFallbackClawHubToNpm(result: { ok: false; code?: string }): boolean { return ( result.code === CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND || @@ -264,6 +267,29 @@ function collectDownloadableInstallCandidates(params: { ); } +function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: { + snapshot: PluginMetadataSnapshot; + configuredPluginIds: ReadonlySet; + configuredChannelIds: ReadonlySet; +}): Set { + const stalePluginIds = new Set(); + const pluginsById = new Map(params.snapshot.plugins.map((plugin) => [plugin.id, plugin])); + for (const diagnostic of params.snapshot.diagnostics) { + const pluginId = diagnostic.pluginId?.trim(); + if (!pluginId || !diagnostic.message.includes(MISSING_CHANNEL_CONFIG_DESCRIPTOR_DIAGNOSTIC)) { + continue; + } + const plugin = pluginsById.get(pluginId); + const ownsConfiguredChannel = plugin?.channels.some((channelId) => + params.configuredChannelIds.has(channelId), + ); + if (params.configuredPluginIds.has(pluginId) || ownsConfiguredChannel) { + stalePluginIds.add(pluginId); + } + } + return stalePluginIds; +} + async function installCandidate(params: { candidate: DownloadableInstallCandidate; records: Record; @@ -405,15 +431,22 @@ async function repairMissingPluginInstalls(params: { env?: NodeJS.ProcessEnv; }): Promise<{ changes: string[]; warnings: string[] }> { const env = params.env ?? process.env; - const knownIds = new Set( - loadManifestMetadataSnapshot({ - config: params.cfg, - env, - }).plugins.map((plugin) => plugin.id), - ); + const snapshot = loadManifestMetadataSnapshot({ + config: params.cfg, + env, + }); + const knownIds = new Set(snapshot.plugins.map((plugin) => plugin.id)); + const configuredPluginIdsWithStaleDescriptors = + collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({ + snapshot, + configuredPluginIds: params.pluginIds, + configuredChannelIds: params.channelIds, + }); const records = await loadInstalledPluginIndexInstallRecords({ env }); const missingRecordedPluginIds = Object.keys(records).filter( - (pluginId) => params.pluginIds.has(pluginId) && !knownIds.has(pluginId), + (pluginId) => + (params.pluginIds.has(pluginId) && !knownIds.has(pluginId)) || + configuredPluginIdsWithStaleDescriptors.has(pluginId), ); const changes: string[] = []; const warnings: string[] = []; @@ -429,6 +462,7 @@ async function repairMissingPluginInstalls(params: { }, }, pluginIds: missingRecordedPluginIds, + updateChannel: params.cfg.update?.channel, logger: { warn: (message) => warnings.push(message), error: (message) => warnings.push(message),