fix(doctor): repair phantom configured plugin installs

This commit is contained in:
Vincent Koc
2026-05-02 17:03:53 -07:00
parent c8ab22997b
commit 4bc6b9d7cf
3 changed files with 91 additions and 1 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Channels/setup: label installable channel picker hints as remote npm installs and hide remote install hints for bundled plugins that already ship with OpenClaw.
- 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`.
- Doctor/plugins: repair configured external plugin installs whose persisted install record points at a missing package directory, so upgrades reconcile phantom npm metadata before plugin runtime validation. Thanks @vincentkoc.
- Active Memory: keep non-empty `memory_search` results from being fast-failed as empty when debug telemetry reports zero hits.
- 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.
- Plugins/install: allow official catalog-matched npm channel plugins such as Feishu to pass the trusted install scanner path while keeping spoofed package names blocked. Thanks @vincentkoc.

View File

@@ -791,6 +791,79 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(result.changes).toEqual(['Repaired missing configured plugin "demo".']);
});
it("reinstalls a known configured plugin when its recorded install path is missing", async () => {
const records = {
discord: {
source: "npm",
spec: "@openclaw/discord",
installPath: "/tmp/openclaw-missing-discord-install-record",
},
};
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "discord",
channels: ["discord"],
},
],
diagnostics: [],
});
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: {
plugins: {
entries: {
discord: { enabled: true },
},
},
channels: {
discord: { enabled: true },
},
},
env: {},
});
expect(mocks.updateNpmInstalledPlugins).toHaveBeenCalledWith(
expect.objectContaining({
pluginIds: ["discord"],
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.changes).toEqual(['Repaired missing configured plugin "discord".']);
});
it("updates a configured plugin when its installed manifest lacks channel config descriptors", async () => {
const records = {
discord: {

View File

@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import path from "node:path";
import {
listExplicitlyDisabledChannelIdsForConfig,
listPotentialConfiguredChannelIds,
@@ -24,6 +26,7 @@ import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-sn
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js";
import { resolveUserPath } from "../../../utils.js";
import { asObjectRecord } from "./object.js";
type DownloadableInstallCandidate = {
@@ -290,6 +293,18 @@ function collectConfiguredPluginIdsWithMissingChannelConfigDescriptors(params: {
return stalePluginIds;
}
function isInstalledRecordMissingOnDisk(
record: PluginInstallRecord | undefined,
env: NodeJS.ProcessEnv,
): boolean {
const installPath = record?.installPath?.trim();
if (!installPath) {
return true;
}
const resolved = resolveUserPath(installPath, env);
return !existsSync(path.join(resolved, "package.json"));
}
async function installCandidate(params: {
candidate: DownloadableInstallCandidate;
records: Record<string, PluginInstallRecord>;
@@ -445,7 +460,8 @@ async function repairMissingPluginInstalls(params: {
const records = await loadInstalledPluginIndexInstallRecords({ env });
const missingRecordedPluginIds = Object.keys(records).filter(
(pluginId) =>
(params.pluginIds.has(pluginId) && !knownIds.has(pluginId)) ||
(params.pluginIds.has(pluginId) &&
(!knownIds.has(pluginId) || isInstalledRecordMissingOnDisk(records[pluginId], env))) ||
configuredPluginIdsWithStaleDescriptors.has(pluginId),
);
const changes: string[] = [];