fix: repair stale configured channel plugins

This commit is contained in:
Peter Steinberger
2026-05-02 23:12:11 +01:00
parent 123a507fa2
commit 192e750035
3 changed files with 137 additions and 7 deletions

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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<string>;
configuredChannelIds: ReadonlySet<string>;
}): Set<string> {
const stalePluginIds = new Set<string>();
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<string, PluginInstallRecord>;
@@ -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),