fix: repair bundled plugin shadow cleanup

This commit is contained in:
Peter Steinberger
2026-05-04 10:17:37 +01:00
parent dade5f9133
commit b7ce9439e7
9 changed files with 257 additions and 17 deletions

View File

@@ -188,6 +188,28 @@ function createCurrentIndex(): InstalledPluginIndex {
};
}
function createCurrentIndexWithNpmRecord(params: {
pluginId: string;
packageName: string;
packageDir: string;
version: string;
}): InstalledPluginIndex {
return {
...createCurrentIndex(),
installRecords: {
[params.pluginId]: {
source: "npm",
spec: `${params.packageName}@${params.version}`,
installPath: params.packageDir,
version: params.version,
resolvedName: params.packageName,
resolvedVersion: params.version,
resolvedSpec: `${params.packageName}@${params.version}`,
},
},
};
}
describe("maybeRepairPluginRegistryState", () => {
it("refreshes an existing registry during repair", async () => {
const stateDir = makeTempDir();
@@ -334,6 +356,65 @@ describe("maybeRepairPluginRegistryState", () => {
);
});
it("removes recovered npm install records when a managed package shadows a bundled plugin", async () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled", "google-meet");
fs.mkdirSync(bundledDir, { recursive: true });
const managed = createManagedNpmPlugin({
stateDir,
id: "google-meet",
packageName: "@openclaw/google-meet",
version: "2026.5.3",
});
await writePersistedInstalledPluginIndex(
createCurrentIndexWithNpmRecord({
pluginId: "google-meet",
packageName: "@openclaw/google-meet",
packageDir: managed.packageDir,
version: "2026.5.3",
}),
{ stateDir },
);
await maybeRepairPluginRegistryState({
stateDir,
candidates: [
createBundledCandidate({
rootDir: bundledDir,
id: "google-meet",
packageName: "@openclaw/google-meet",
version: "2026.5.3",
}),
],
env: hermeticEnv(),
config: {
plugins: {
allow: ["google-meet"],
entries: {
"google-meet": {
enabled: true,
config: {},
},
},
},
},
prompter: { shouldRepair: true },
});
expect(fs.existsSync(managed.packageDir)).toBe(false);
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
installRecords: {},
refreshReason: "migration",
plugins: [
expect.objectContaining({
pluginId: "google-meet",
origin: "bundled",
rootDir: bundledDir,
}),
],
});
});
it("removes stale managed npm packages from the package lock during repair", async () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled", "google-meet");

View File

@@ -5,7 +5,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { saveJsonFile } from "../infra/json-file.js";
import { resolveDefaultPluginNpmDir } from "../plugins/install-paths.js";
import type { InstalledPluginIndexRecordStoreOptions } from "../plugins/installed-plugin-index-records.js";
import { readPersistedInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
import { note } from "../terminal/note.js";
@@ -81,7 +80,6 @@ function readPluginManifestId(packageDir: string): string | undefined {
function listStaleManagedNpmBundledPlugins(
params: PluginRegistryDoctorRepairParams,
): StaleManagedNpmBundledPlugin[] {
const persistedInstallRecords = readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {};
const currentBundled = loadInstalledPluginIndex({
...params,
installRecords: {},
@@ -109,10 +107,6 @@ function listStaleManagedNpmBundledPlugins(
if (!pluginId || pluginId !== bundled.pluginId) {
continue;
}
const persistedRecord = persistedInstallRecords[pluginId];
if (persistedRecord?.source === "npm") {
continue;
}
stale.push({
pluginId,
packageName,
@@ -195,7 +189,7 @@ function removeManagedNpmPackageLockDependency(params: {
}
}
function maybeRepairStaleManagedNpmBundledPlugins(
export function maybeRepairStaleManagedNpmBundledPlugins(
params: PluginRegistryDoctorRepairParams,
): boolean {
const stale = listStaleManagedNpmBundledPlugins(params);

View File

@@ -4,6 +4,7 @@ import { runDoctorRepairSequence } from "./repair-sequencing.js";
const mocks = vi.hoisted(() => ({
applyPluginAutoEnable: vi.fn(),
maybeRepairStaleManagedNpmBundledPlugins: vi.fn(),
maybeRepairStalePluginConfig: vi.fn(),
repairMissingConfiguredPluginInstalls: vi.fn(),
}));
@@ -12,6 +13,10 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: mocks.applyPluginAutoEnable,
}));
vi.mock("../doctor-plugin-registry.js", () => ({
maybeRepairStaleManagedNpmBundledPlugins: mocks.maybeRepairStaleManagedNpmBundledPlugins,
}));
vi.mock("./shared/missing-configured-plugin-install.js", () => ({
repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls,
}));
@@ -145,6 +150,7 @@ describe("doctor repair sequencing", () => {
config: params.config,
changes: [],
}));
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false);
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
changes: [],
warnings: [],
@@ -228,6 +234,54 @@ describe("doctor repair sequencing", () => {
expect(result.warningNotes.join("\n")).not.toContain("\r");
});
it("removes managed npm bundled-plugin shadows before missing plugin install repair", async () => {
const events: string[] = [];
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation(() => {
events.push("cleanup");
return true;
});
mocks.repairMissingConfiguredPluginInstalls.mockImplementation(async () => {
events.push("missing-installs");
return { changes: [], warnings: [] };
});
await runDoctorRepairSequence({
state: {
cfg: {
plugins: {
entries: {
"google-meet": { enabled: true },
},
},
} as OpenClawConfig,
candidate: {
plugins: {
entries: {
"google-meet": { enabled: true },
},
},
} as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
doctorFixCommand: "openclaw doctor --fix",
});
expect(events).toEqual(["cleanup", "missing-installs"]);
expect(mocks.maybeRepairStaleManagedNpmBundledPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
"google-meet": { enabled: true },
}),
}),
}),
prompter: { shouldRepair: true },
}),
);
});
it("emits Discord warnings when unsafe numeric ids block repair", async () => {
const result = await runDoctorRepairSequence({
state: {

View File

@@ -1,5 +1,6 @@
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { maybeRepairStaleManagedNpmBundledPlugins } from "../doctor-plugin-registry.js";
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
import {
@@ -67,6 +68,11 @@ export async function runDoctorRepairSequence(params: {
}
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
maybeRepairStaleManagedNpmBundledPlugins({
config: state.candidate,
env,
prompter: { shouldRepair: true },
});
const missingConfiguredPluginInstallRepair = await repairMissingConfiguredPluginInstalls({
cfg: state.candidate,
env,

View File

@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
installPluginFromNpmSpec: vi.fn(),
listChannelPluginCatalogEntries: vi.fn(),
listOfficialExternalPluginCatalogEntries: vi.fn(),
loadInstalledPluginIndex: vi.fn(),
loadInstalledPluginIndexInstallRecords: vi.fn(),
loadPluginMetadataSnapshot: vi.fn(),
getOfficialExternalPluginCatalogManifest: vi.fn(
@@ -33,6 +34,11 @@ vi.mock("../../../plugins/installed-plugin-index-records.js", () => ({
mocks.writePersistedInstalledPluginIndexInstallRecords,
}));
vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/installed-plugin-index.js")>()),
loadInstalledPluginIndex: mocks.loadInstalledPluginIndex,
}));
vi.mock("../../../plugins/install-paths.js", () => ({
resolveDefaultPluginExtensionsDir: mocks.resolveDefaultPluginExtensionsDir,
}));
@@ -76,6 +82,11 @@ describe("repairMissingConfiguredPluginInstalls", () => {
plugins: [],
diagnostics: [],
});
mocks.loadInstalledPluginIndex.mockReturnValue({
plugins: [],
diagnostics: [],
installRecords: {},
});
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({});
mocks.listChannelPluginCatalogEntries.mockReturnValue([]);
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([]);
@@ -663,6 +674,75 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
});
it("uses current bundled discovery to remove records before stale snapshots can reinstall official plugins", async () => {
const records = {
"google-meet": {
source: "npm",
spec: "@openclaw/google-meet",
resolvedName: "@openclaw/google-meet",
installPath: "/missing/google-meet",
},
};
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "google-meet",
origin: "npm",
packageName: "@openclaw/google-meet",
},
],
diagnostics: [],
});
mocks.loadInstalledPluginIndex.mockReturnValue({
plugins: [
{
pluginId: "google-meet",
origin: "bundled",
packageName: "@openclaw/google-meet",
},
],
diagnostics: [],
installRecords: {},
});
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "google-meet",
label: "Google Meet",
install: { npmSpec: "@openclaw/google-meet" },
openclaw: {
id: "google-meet",
install: { npmSpec: "@openclaw/google-meet" },
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
"google-meet": { enabled: true },
},
},
},
env: {},
});
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
{},
{
env: {},
},
);
expect(result).toEqual({
changes: ['Removed stale managed install record for bundled plugin "google-meet".'],
warnings: [],
});
});
it.each([
[
"npm",

View File

@@ -16,9 +16,9 @@ import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-path
import { installPluginFromNpmSpec } from "../../../plugins/install.js";
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js";
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
import type { PluginPackageInstall } from "../../../plugins/manifest.js";
import {
listOfficialExternalPluginCatalogEntries,
@@ -44,6 +44,11 @@ type DownloadableInstallCandidate = {
defaultChoice?: PluginPackageInstall["defaultChoice"];
};
type BundledPluginPackageDescriptor = {
name?: string;
packageName?: string;
};
const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[] = [
{
pluginId: "acpx",
@@ -417,7 +422,7 @@ function isUpdatePackageDoctorPass(env: NodeJS.ProcessEnv): boolean {
function recordMatchesBundledPackage(
record: PluginInstallRecord,
bundled: PluginManifestRecord,
bundled: BundledPluginPackageDescriptor,
): boolean {
const packageName = bundled.packageName?.trim() || bundled.name?.trim();
if (!packageName) {
@@ -598,18 +603,35 @@ async function repairMissingPluginInstalls(params: {
config: params.cfg,
env,
});
const knownIds = new Set(snapshot.plugins.map((plugin) => plugin.id));
const currentBundledPlugins = loadInstalledPluginIndex({
config: params.cfg,
env,
installRecords: {},
}).plugins.filter((plugin) => plugin.origin === "bundled");
const knownIds = new Set([
...snapshot.plugins.map((plugin) => plugin.id),
...currentBundledPlugins.map((plugin) => plugin.pluginId),
]);
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
cfg: params.cfg,
env,
snapshot,
configuredChannelIds: params.channelIds,
});
const bundledPluginsById = new Map(
snapshot.plugins
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
...snapshot.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => [plugin.id, plugin]),
);
.map((plugin) => [plugin.id, plugin] as const),
...currentBundledPlugins.map(
(plugin) =>
[
plugin.pluginId,
{
packageName: plugin.packageName,
},
] as const,
),
]);
const configuredPluginIdsWithStaleDescriptors =
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
snapshot,
@@ -724,6 +746,9 @@ async function repairMissingPluginInstalls(params: {
? new Set([...(params.blockedPluginIds ?? []), ...deferredPluginIds])
: params.blockedPluginIds,
})) {
if (bundledPluginsById.has(candidate.pluginId)) {
continue;
}
const hasUsableRecord =
Object.hasOwn(nextRecords, candidate.pluginId) &&
!isInstalledRecordMissingOnDisk(nextRecords[candidate.pluginId], env);