fix: preserve disabled plugin registry migration

This commit is contained in:
Shakker
2026-04-25 22:14:40 +01:00
parent 7d343b0b10
commit cbfc0ddfd1
3 changed files with 159 additions and 9 deletions

View File

@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/registry: preserve explicit disabled plugin records during registry migration without persisting every unused bundled plugin discovered on disk. Thanks @shakkernerd.
- Windows/native: keep CLI startup and bundled provider plugin loading off
Windows ESM raw-path failure paths, fixing native onboarding/install smoke on
Node 24. Thanks @steipete.

View File

@@ -38,7 +38,11 @@ function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
};
}
function createCandidate(rootDir: string, id = "demo"): PluginCandidate {
function createCandidate(
rootDir: string,
id = "demo",
origin: PluginCandidate["origin"] = "global",
): PluginCandidate {
fs.writeFileSync(
path.join(rootDir, "index.ts"),
"throw new Error('runtime entry should not load while migrating plugin registry');\n",
@@ -58,7 +62,7 @@ function createCandidate(rootDir: string, id = "demo"): PluginCandidate {
idHint: id,
source: path.join(rootDir, "index.ts"),
rootDir,
origin: "global",
origin,
};
}
@@ -127,19 +131,22 @@ describe("plugin registry install migration", () => {
});
});
it("persists only plugins enabled by the central config policy", async () => {
it("persists migration-relevant plugin records without dropping explicit disabled state", async () => {
const stateDir = makeTempDir();
const enabledDir = path.join(stateDir, "plugins", "enabled-demo");
const disabledDir = path.join(stateDir, "plugins", "disabled-demo");
const unusedBundledDir = path.join(stateDir, "plugins", "unused-bundled");
fs.mkdirSync(enabledDir, { recursive: true });
fs.mkdirSync(disabledDir, { recursive: true });
fs.mkdirSync(unusedBundledDir, { recursive: true });
await expect(
migratePluginRegistryForInstall({
stateDir,
candidates: [
createCandidate(enabledDir, "enabled-demo"),
createCandidate(disabledDir, "disabled-demo"),
createCandidate(disabledDir, "disabled-demo", "bundled"),
createCandidate(unusedBundledDir, "unused-bundled", "bundled"),
],
readConfig: async () => ({
plugins: {
@@ -155,15 +162,24 @@ describe("plugin registry install migration", () => {
).resolves.toMatchObject({
status: "migrated",
current: {
plugins: [expect.objectContaining({ pluginId: "enabled-demo" })],
plugins: [
expect.objectContaining({ pluginId: "enabled-demo", enabled: true }),
expect.objectContaining({ pluginId: "disabled-demo", enabled: false }),
],
},
});
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
plugins: [expect.objectContaining({ pluginId: "enabled-demo" })],
plugins: [
expect.objectContaining({ pluginId: "enabled-demo", enabled: true }),
expect.objectContaining({ pluginId: "disabled-demo", enabled: false }),
],
});
const persisted = await readPersistedInstalledPluginIndex({ stateDir });
expect(persisted?.plugins.map((plugin) => plugin.pluginId)).toEqual(["enabled-demo"]);
expect(persisted?.plugins.map((plugin) => plugin.pluginId)).toEqual([
"enabled-demo",
"disabled-demo",
]);
});
it("supports dry-run preflight without reading config or writing the registry", async () => {

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import { normalizeProviderId } from "../../../agents/provider-id.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import {
loadPluginInstallRecords,
@@ -13,9 +14,9 @@ import {
type InstalledPluginIndexStoreOptions,
} from "../../../plugins/installed-plugin-index-store.js";
import {
listEnabledInstalledPluginRecords,
loadInstalledPluginIndex,
type InstalledPluginIndex,
type InstalledPluginIndexRecord,
type LoadInstalledPluginIndexParams,
} from "../../../plugins/installed-plugin-index.js";
@@ -111,6 +112,134 @@ async function readMigrationConfig(
return await configModule.readBestEffortConfig();
}
function normalizeRegistryReference(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
function createMigrationPluginIdNormalizer(
index: InstalledPluginIndex,
): (pluginId: string) => string {
const aliases = new Map<string, string>();
for (const plugin of index.plugins) {
const pluginId = normalizeRegistryReference(plugin.pluginId);
if (!pluginId) {
continue;
}
aliases.set(pluginId, plugin.pluginId);
for (const alias of [
...plugin.contributions.providers,
...plugin.contributions.channels,
...plugin.contributions.setupProviders,
...plugin.contributions.cliBackends,
...plugin.contributions.modelCatalogProviders,
]) {
const normalizedAlias = normalizeRegistryReference(alias);
if (normalizedAlias && !aliases.has(normalizedAlias)) {
aliases.set(normalizedAlias, plugin.pluginId);
}
}
}
return (pluginId: string) => {
const normalized = normalizeRegistryReference(pluginId);
return normalized ? (aliases.get(normalized) ?? pluginId.trim()) : pluginId.trim();
};
}
function addPluginReference(
references: Set<string>,
normalizePluginId: (pluginId: string) => string,
value: unknown,
): void {
if (typeof value !== "string") {
return;
}
const normalized = normalizePluginId(value);
if (normalized) {
references.add(normalized);
}
}
function listConfiguredChannelIds(config: OpenClawConfig): Set<string> {
const channels = config.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return new Set();
}
return new Set(
Object.keys(channels)
.map((channelId) => normalizeRegistryReference(channelId))
.filter((channelId): channelId is string => Boolean(channelId)),
);
}
function listConfiguredModelProviderIds(config: OpenClawConfig): Set<string> {
const providers = config.models?.providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
return new Set();
}
return new Set(
Object.keys(providers)
.map((providerId) => normalizeProviderId(providerId))
.filter(Boolean),
);
}
export function listMigrationRelevantPluginRecords(params: {
index: InstalledPluginIndex;
config: OpenClawConfig;
installRecords: Record<string, unknown>;
}): readonly InstalledPluginIndexRecord[] {
const normalizePluginId = createMigrationPluginIdNormalizer(params.index);
const referencedPluginIds = new Set<string>();
const installedPluginIds = new Set<string>();
for (const pluginId of Object.keys(params.installRecords)) {
addPluginReference(installedPluginIds, normalizePluginId, pluginId);
}
const plugins = params.config.plugins;
for (const pluginId of plugins?.allow ?? []) {
addPluginReference(referencedPluginIds, normalizePluginId, pluginId);
}
for (const pluginId of plugins?.deny ?? []) {
addPluginReference(referencedPluginIds, normalizePluginId, pluginId);
}
for (const pluginId of Object.keys(plugins?.entries ?? {})) {
addPluginReference(referencedPluginIds, normalizePluginId, pluginId);
}
for (const pluginId of Object.values(plugins?.slots ?? {})) {
if (normalizeRegistryReference(pluginId) === "none") {
continue;
}
addPluginReference(referencedPluginIds, normalizePluginId, pluginId);
}
const configuredChannelIds = listConfiguredChannelIds(params.config);
const configuredModelProviderIds = listConfiguredModelProviderIds(params.config);
return params.index.plugins.filter((plugin) => {
if (plugin.origin !== "bundled") {
return true;
}
if (installedPluginIds.has(plugin.pluginId) || referencedPluginIds.has(plugin.pluginId)) {
return true;
}
if (
plugin.contributions.channels.some((channelId) =>
configuredChannelIds.has(normalizeRegistryReference(channelId) ?? ""),
)
) {
return true;
}
return plugin.contributions.providers.some((providerId) =>
configuredModelProviderIds.has(normalizeProviderId(providerId)),
);
});
}
export async function migratePluginRegistryForInstall(
params: PluginRegistryInstallMigrationParams = {},
): Promise<PluginRegistryInstallMigrationResult> {
@@ -139,7 +268,11 @@ export async function migratePluginRegistryForInstall(
const current: InstalledPluginIndex = {
...candidateIndex,
refreshReason: "migration",
plugins: listEnabledInstalledPluginRecords(candidateIndex, config),
plugins: listMigrationRelevantPluginRecords({
index: candidateIndex,
config,
installRecords,
}),
};
if (Object.keys(installRecords).length > 0) {
await writePersistedPluginInstallLedger(installRecords, params);