mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +00:00
fix: repair bundled plugin shadow cleanup
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user