From 1ee5654220c37d78d0e35f860b97d68b551e1f7b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 00:02:49 -0700 Subject: [PATCH] fix(plugins): persist registry contribution metadata --- src/plugins/installed-plugin-index-store.ts | 15 +++ src/plugins/installed-plugin-index.test.ts | 10 ++ src/plugins/installed-plugin-index.ts | 91 +++++++++++++++++++ src/plugins/plugin-registry.test.ts | 18 ++++ src/plugins/plugin-registry.ts | 96 ++++++++++++++++++++ src/plugins/status.registry-snapshot.test.ts | 71 ++++++++++++++- src/plugins/status.ts | 40 +++----- 7 files changed, 315 insertions(+), 26 deletions(-) diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index a5d23b3467f..dc33d759295 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -48,6 +48,11 @@ const InstalledPluginIndexStartupSchema = z const InstalledPluginIndexRecordSchema = z .object({ pluginId: z.string(), + contributionMetadataVersion: z.number().optional(), + name: z.string().optional(), + description: z.string().optional(), + manifestVersion: z.string().optional(), + legacyPluginIds: StringArraySchema.optional(), packageName: z.string().optional(), packageVersion: z.string().optional(), installRecord: z.record(z.string(), z.unknown()).optional(), @@ -58,6 +63,16 @@ const InstalledPluginIndexRecordSchema = z manifestHash: z.string(), format: z.string().optional(), bundleFormat: z.string().optional(), + bundleCapabilities: StringArraySchema.optional(), + kind: z.unknown().optional(), + channels: StringArraySchema.optional(), + providers: StringArraySchema.optional(), + cliBackends: StringArraySchema.optional(), + setupProviders: StringArraySchema.optional(), + channelConfigs: StringArraySchema.optional(), + modelCatalogProviders: StringArraySchema.optional(), + commandAliases: StringArraySchema.optional(), + contractKeys: StringArraySchema.optional(), source: z.string().optional(), setupSource: z.string().optional(), packageJson: z diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 14d06879758..798c3bfb2b8 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -175,8 +175,18 @@ describe("installed plugin index", () => { plugins: [ { pluginId: "demo", + contributionMetadataVersion: 1, + name: "Demo", packageName: "@vendor/demo-plugin", packageVersion: "1.2.3", + channels: ["demo-chat"], + channelConfigs: ["demo-chat"], + cliBackends: ["demo-cli", "setup-cli"], + commandAliases: ["demo-command"], + contractKeys: ["tools"], + modelCatalogProviders: ["demo"], + providers: ["demo"], + setupProviders: ["demo"], origin: "global", rootDir: fixture.rootDir, source: path.join(fixture.rootDir, "index.ts"), diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index ca2e63af6f1..c7d2f9f6840 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -23,6 +23,7 @@ import { hasKind } from "./slots.js"; export const INSTALLED_PLUGIN_INDEX_VERSION = 1; export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1; +export const INSTALLED_PLUGIN_CONTRIBUTION_METADATA_VERSION = 1; export const INSTALLED_PLUGIN_INDEX_WARNING = "DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead."; @@ -74,6 +75,11 @@ export type InstalledPluginPackageChannelInfo = Pick< export type InstalledPluginIndexRecord = { pluginId: string; + contributionMetadataVersion?: typeof INSTALLED_PLUGIN_CONTRIBUTION_METADATA_VERSION; + name?: string; + description?: string; + manifestVersion?: string; + legacyPluginIds?: readonly string[]; packageName?: string; packageVersion?: string; /** @@ -93,6 +99,16 @@ export type InstalledPluginIndexRecord = { manifestHash: string; format?: PluginManifestRecord["format"]; bundleFormat?: PluginManifestRecord["bundleFormat"]; + bundleCapabilities?: readonly string[]; + kind?: PluginManifestRecord["kind"]; + channels?: readonly string[]; + providers?: readonly string[]; + cliBackends?: readonly string[]; + setupProviders?: readonly string[]; + channelConfigs?: readonly string[]; + modelCatalogProviders?: readonly string[]; + commandAliases?: readonly string[]; + contractKeys?: readonly string[]; source?: string; setupSource?: string; packageJson?: { @@ -209,6 +225,18 @@ function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupI }; } +function collectContractKeys(record: PluginManifestRecord): readonly string[] | undefined { + const contracts = record.contracts; + if (!contracts) { + return undefined; + } + return normalizeStringList( + Object.entries(contracts).flatMap(([key, value]) => + Array.isArray(value) && value.length > 0 ? [key] : [], + ), + ); +} + function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] { const codes: PluginCompatCode[] = []; if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) { @@ -307,6 +335,10 @@ function normalizeStringListField(value: unknown): readonly string[] | undefined return normalized.length > 0 ? normalized : undefined; } +function normalizeStringList(values: readonly string[] | undefined): readonly string[] | undefined { + return normalizeStringListField(values); +} + function normalizePackageChannel( channel: PluginPackageChannel | undefined, ): InstalledPluginPackageChannelInfo | undefined { @@ -576,6 +608,7 @@ function buildInstalledPluginIndex( }).enabled; const indexRecord: InstalledPluginIndexRecord = { pluginId: record.id, + contributionMetadataVersion: INSTALLED_PLUGIN_CONTRIBUTION_METADATA_VERSION, manifestPath: record.manifestPath, manifestHash, source: record.source, @@ -585,12 +618,70 @@ function buildInstalledPluginIndex( startup: buildStartupInfo(record), compat: collectCompatCodes(record), }; + if (record.name) { + indexRecord.name = record.name; + } + if (record.description) { + indexRecord.description = record.description; + } + if (record.version) { + indexRecord.manifestVersion = record.version; + } + const legacyPluginIds = normalizeStringList(record.legacyPluginIds); + if (legacyPluginIds) { + indexRecord.legacyPluginIds = legacyPluginIds; + } if (record.format && record.format !== "openclaw") { indexRecord.format = record.format; } if (record.bundleFormat) { indexRecord.bundleFormat = record.bundleFormat; } + if (record.bundleCapabilities?.length) { + indexRecord.bundleCapabilities = normalizeStringList(record.bundleCapabilities); + } + if (record.kind) { + indexRecord.kind = record.kind; + } + const channels = normalizeStringList(record.channels); + if (channels) { + indexRecord.channels = channels; + } + const providers = normalizeStringList(record.providers); + if (providers) { + indexRecord.providers = providers; + } + const cliBackends = normalizeStringList([ + ...record.cliBackends, + ...(record.setup?.cliBackends ?? []), + ]); + if (cliBackends) { + indexRecord.cliBackends = cliBackends; + } + const setupProviders = normalizeStringList( + record.setup?.providers?.map((provider) => provider.id), + ); + if (setupProviders) { + indexRecord.setupProviders = setupProviders; + } + const channelConfigs = normalizeStringList(Object.keys(record.channelConfigs ?? {})); + if (channelConfigs) { + indexRecord.channelConfigs = channelConfigs; + } + const modelCatalogProviders = normalizeStringList( + Object.keys(record.modelCatalog?.providers ?? {}), + ); + if (modelCatalogProviders) { + indexRecord.modelCatalogProviders = modelCatalogProviders; + } + const commandAliases = normalizeStringList(record.commandAliases?.map((alias) => alias.name)); + if (commandAliases) { + indexRecord.commandAliases = commandAliases; + } + const contractKeys = collectContractKeys(record); + if (contractKeys) { + indexRecord.contractKeys = contractKeys; + } if (record.enabledByDefault === true) { indexRecord.enabledByDefault = true; } diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 3020411627d..9e3e0efb6a0 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -185,6 +185,24 @@ describe("plugin registry facade", () => { ).toEqual(["demo"]); }); + it("resolves indexed contribution owners without reopening manifest roots", () => { + const rootDir = makeTempDir(); + const candidate = createCandidate(rootDir); + const index = loadPluginRegistrySnapshot({ + candidates: [candidate], + env: hermeticEnv(), + preferPersisted: false, + }); + fs.rmSync(rootDir, { recursive: true, force: true }); + + expect(listPluginContributionIds({ index, contribution: "providers" })).toEqual(["demo"]); + expect(resolveProviderOwners({ index, providerId: "demo" })).toEqual(["demo"]); + expect(resolveChannelOwners({ index, channelId: "demo-chat" })).toEqual(["demo"]); + expect(resolveCliBackendOwners({ index, cliBackendId: "demo-setup-cli" })).toEqual(["demo"]); + expect(resolveSetupProviderOwners({ index, setupProviderId: "demo-setup" })).toEqual(["demo"]); + expect(createPluginRegistryIdNormalizer(index)("demo-chat")).toBe("demo"); + }); + it("keeps disabled records inspectable while excluding owners by default", () => { const rootDir = makeTempDir(); const candidate = createCandidate(rootDir); diff --git a/src/plugins/plugin-registry.ts b/src/plugins/plugin-registry.ts index 207a3e35d96..81998747f4a 100644 --- a/src/plugins/plugin-registry.ts +++ b/src/plugins/plugin-registry.ts @@ -12,6 +12,7 @@ import { type InstalledPluginIndexStoreOptions, } from "./installed-plugin-index-store.js"; import { + INSTALLED_PLUGIN_CONTRIBUTION_METADATA_VERSION, getInstalledPluginRecord, extractPluginInstallRecordsFromInstalledPluginIndex, isInstalledPluginEnabled, @@ -207,6 +208,48 @@ function listManifestContributionIds( return []; } +function hasIndexedContributionMetadata(plugin: InstalledPluginIndexRecord): boolean { + return plugin.contributionMetadataVersion === INSTALLED_PLUGIN_CONTRIBUTION_METADATA_VERSION; +} + +function listIndexedContributionIds( + plugin: InstalledPluginIndexRecord, + contribution: PluginRegistryContributionKey, +): readonly string[] { + switch (contribution) { + case "providers": + return plugin.providers ?? []; + case "channels": + return plugin.channels ?? []; + case "channelConfigs": + return plugin.channelConfigs ?? []; + case "setupProviders": + return plugin.setupProviders ?? []; + case "cliBackends": + return plugin.cliBackends ?? []; + case "modelCatalogProviders": + return plugin.modelCatalogProviders ?? []; + case "commandAliases": + return plugin.commandAliases ?? []; + case "contracts": + return plugin.contractKeys ?? []; + } + return []; +} + +function listContributionIndexRecords(params: { + index: PluginRegistrySnapshot; + includeDisabled?: boolean; + config?: OpenClawConfig; +}): readonly InstalledPluginIndexRecord[] { + if (params.includeDisabled) { + return params.index.plugins; + } + return params.index.plugins.filter((plugin) => + isInstalledPluginEnabled(params.index, plugin.pluginId, params.config), + ); +} + function resolveContributionPluginIds(params: { index: PluginRegistrySnapshot; includeDisabled?: boolean; @@ -264,6 +307,35 @@ export function createPluginRegistryIdNormalizer( aliases.set(normalizePluginRegistryAliasKey(pluginId), plugin.pluginId); } } + if (index.plugins.every(hasIndexedContributionMetadata)) { + for (const plugin of [...index.plugins].toSorted((left, right) => + left.pluginId.localeCompare(right.pluginId), + )) { + const pluginId = normalizePluginRegistryAlias(plugin.pluginId); + if (!pluginId) { + continue; + } + for (const alias of [ + plugin.pluginId, + ...(plugin.providers ?? []), + ...(plugin.channels ?? []), + ...(plugin.setupProviders ?? []), + ...(plugin.cliBackends ?? []), + ...(plugin.modelCatalogProviders ?? []), + ...(plugin.legacyPluginIds ?? []), + ]) { + const normalizedAlias = normalizePluginRegistryAlias(alias); + const normalizedAliasKey = normalizePluginRegistryAliasKey(alias); + if (normalizedAlias && !aliases.has(normalizedAliasKey)) { + aliases.set(normalizedAliasKey, pluginId); + } + } + } + return (pluginId: string) => { + const trimmed = normalizePluginRegistryAlias(pluginId); + return aliases.get(normalizePluginRegistryAliasKey(trimmed)) ?? trimmed; + }; + } const registry = loadPluginManifestRegistryForInstalledIndex({ index, includeDisabled: true, @@ -405,6 +477,16 @@ export function listPluginContributionIds( params: ListPluginContributionIdsParams, ): readonly string[] { const index = resolveSnapshot(params); + const records = listContributionIndexRecords({ + index, + includeDisabled: params.includeDisabled, + config: params.config, + }); + if (records.every(hasIndexedContributionMetadata)) { + return sortUnique( + records.flatMap((plugin) => listIndexedContributionIds(plugin, params.contribution)), + ); + } const registry = loadContributionManifestRegistry({ ...params, index, @@ -422,6 +504,20 @@ export function resolvePluginContributionOwners( ? (contributionId: string) => contributionId === params.matches : params.matches; const index = resolveSnapshot(params); + const records = listContributionIndexRecords({ + index, + includeDisabled: params.includeDisabled, + config: params.config, + }); + if (records.every(hasIndexedContributionMetadata)) { + return sortUnique( + records.flatMap((plugin) => + listIndexedContributionIds(plugin, params.contribution).some(matcher) + ? [plugin.pluginId] + : [], + ), + ); + } const registry = loadContributionManifestRegistry({ ...params, index, diff --git a/src/plugins/status.registry-snapshot.test.ts b/src/plugins/status.registry-snapshot.test.ts index 245e0fd9c8f..ff6f927379f 100644 --- a/src/plugins/status.registry-snapshot.test.ts +++ b/src/plugins/status.registry-snapshot.test.ts @@ -3,6 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { clearPluginDiscoveryCache } from "./discovery.js"; +import { writePersistedInstalledPluginIndex } from "./installed-plugin-index-store.js"; +import { loadInstalledPluginIndex } from "./installed-plugin-index.js"; import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; import { buildPluginRegistrySnapshotReport } from "./status.js"; @@ -23,7 +25,7 @@ afterEach(() => { }); describe("buildPluginRegistrySnapshotReport", () => { - it("reconstructs list metadata from indexed manifests without importing plugin runtime", () => { + it("reports list metadata from the installed index without importing plugin runtime", () => { const pluginDir = makeTempDir(); const runtimeMarker = path.join(pluginDir, "runtime-loaded.txt"); fs.writeFileSync( @@ -80,4 +82,71 @@ describe("buildPluginRegistrySnapshotReport", () => { }); expect(fs.existsSync(runtimeMarker)).toBe(false); }); + + it("reports persisted indexed metadata without reopening stale manifest roots", async () => { + const pluginDir = makeTempDir(); + const stateDir = makeTempDir(); + const env = { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }; + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@example/openclaw-stale-indexed-demo", + version: "4.5.6", + openclaw: { extensions: ["./index.cjs"] }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "stale-indexed-demo", + name: "Stale Indexed Demo", + description: "Persisted list metadata", + version: "1.0.0", + providers: ["stale-provider"], + commandAliases: [{ name: "stale-command" }], + }), + "utf-8", + ); + fs.writeFileSync(path.join(pluginDir, "index.cjs"), "module.exports = {};\n", "utf-8"); + + const index = loadInstalledPluginIndex({ + config: {}, + env, + candidates: [ + { + idHint: "stale-indexed-demo", + source: path.join(pluginDir, "index.cjs"), + rootDir: pluginDir, + origin: "global", + packageName: "@example/openclaw-stale-indexed-demo", + packageVersion: "4.5.6", + packageDir: pluginDir, + }, + ], + }); + await writePersistedInstalledPluginIndex(index, { stateDir }); + fs.rmSync(pluginDir, { recursive: true, force: true }); + + const report = buildPluginRegistrySnapshotReport({ + config: {}, + env, + }); + + expect(report.registrySource).toBe("persisted"); + expect(report.plugins).toEqual([ + expect.objectContaining({ + id: "stale-indexed-demo", + name: "Stale Indexed Demo", + description: "Persisted list metadata", + version: "4.5.6", + providerIds: ["stale-provider"], + commands: ["stale-command"], + }), + ]); + }); }); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 423ef68c1f7..d4475fca0b9 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -18,8 +18,6 @@ import { type PluginInspectShape, } from "./inspect-shape.js"; import { loadOpenClawPlugins } from "./loader.js"; -import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; -import type { PluginManifestRecord } from "./manifest-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { loadPluginRegistrySnapshotWithMetadata, @@ -157,20 +155,22 @@ type PluginReportParams = { function buildPluginRecordFromInstalledIndex( plugin: import("./installed-plugin-index.js").InstalledPluginIndexRecord, - manifest?: PluginManifestRecord, ): PluginRecord { - const format = plugin.format ?? manifest?.format ?? "openclaw"; - const bundleFormat = plugin.bundleFormat ?? manifest?.bundleFormat; + const format = plugin.format ?? "openclaw"; + const bundleFormat = plugin.bundleFormat; return { id: plugin.pluginId, - name: manifest?.name ?? plugin.packageName ?? plugin.pluginId, - ...(plugin.packageVersion || manifest?.version - ? { version: plugin.packageVersion ?? manifest?.version } + name: plugin.name ?? plugin.packageName ?? plugin.pluginId, + ...(plugin.packageVersion || plugin.manifestVersion + ? { version: plugin.packageVersion ?? plugin.manifestVersion } : {}), - ...(manifest?.description ? { description: manifest.description } : {}), + ...(plugin.description ? { description: plugin.description } : {}), format, ...(bundleFormat ? { bundleFormat } : {}), - ...(manifest?.kind ? { kind: manifest.kind } : {}), + ...(plugin.bundleCapabilities?.length + ? { bundleCapabilities: [...plugin.bundleCapabilities] } + : {}), + ...(plugin.kind ? { kind: plugin.kind } : {}), source: plugin.source ?? plugin.manifestPath, rootDir: plugin.rootDir, origin: plugin.origin, @@ -178,9 +178,9 @@ function buildPluginRecordFromInstalledIndex( status: plugin.enabled ? "loaded" : "disabled", toolNames: [], hookNames: [], - channelIds: [...(manifest?.channels ?? [])], - cliBackendIds: [...(manifest?.cliBackends ?? []), ...(manifest?.setup?.cliBackends ?? [])], - providerIds: [...(manifest?.providers ?? [])], + channelIds: [...(plugin.channels ?? [])], + cliBackendIds: [...(plugin.cliBackends ?? [])], + providerIds: [...(plugin.providers ?? [])], speechProviderIds: [], realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], @@ -196,7 +196,7 @@ function buildPluginRecordFromInstalledIndex( cliCommands: [], services: [], gatewayDiscoveryServiceIds: [], - commands: [...(manifest?.commandAliases?.map((alias) => alias.name) ?? [])], + commands: [...(plugin.commandAliases ?? [])], httpRoutes: 0, hookCount: 0, configSchema: false, @@ -213,20 +213,10 @@ export function buildPluginRegistrySnapshotReport( env: params?.env, workspaceDir: params?.workspaceDir, }); - const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ - index: result.snapshot, - config, - env: params?.env, - workspaceDir: params?.workspaceDir, - includeDisabled: true, - }); - const manifestByPluginId = new Map(manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])); return { workspaceDir: params?.workspaceDir, ...createEmptyPluginRegistry(), - plugins: result.snapshot.plugins.map((plugin) => - buildPluginRecordFromInstalledIndex(plugin, manifestByPluginId.get(plugin.pluginId)), - ), + plugins: result.snapshot.plugins.map((plugin) => buildPluginRecordFromInstalledIndex(plugin)), diagnostics: [...result.snapshot.diagnostics], registrySource: result.source, registryDiagnostics: result.diagnostics,