diff --git a/src/plugins/config-contracts.test.ts b/src/plugins/config-contracts.test.ts index 56f24e7fd6da97..f795d037ec30cf 100644 --- a/src/plugins/config-contracts.test.ts +++ b/src/plugins/config-contracts.test.ts @@ -265,6 +265,97 @@ describe("resolvePluginConfigContractsById", () => { ); }); + it("keeps healthy config contract records after unreadable registry plugin metadata", () => { + const unreadableRecord = { + get id() { + throw new Error("config contract plugin id getter exploded"); + }, + origin: "config", + configContracts: { + compatibilityMigrationPaths: ["plugins.entries.broken.config"], + }, + } as never; + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue( + createRegistry([ + unreadableRecord, + createPluginRecord({ + id: "healthy", + origin: "config", + configContracts: { + compatibilityMigrationPaths: ["plugins.entries.healthy.config"], + }, + }), + ]), + ); + + expect( + resolvePluginConfigContractsById({ + pluginIds: ["healthy"], + fallbackToBundledMetadata: false, + }), + ).toEqual( + new Map([ + [ + "healthy", + { + origin: "config", + configContracts: { + compatibilityMigrationPaths: ["plugins.entries.healthy.config"], + }, + }, + ], + ]), + ); + }); + + it("keeps bundled config contract fallback after unreadable bundled plugin metadata", () => { + const unreadableRecord = { + get id() { + throw new Error("bundled config contract plugin id getter exploded"); + }, + origin: "bundled", + configContracts: { + secretInputs: { + paths: [{ path: "broken.secret", expected: "string" }], + }, + }, + } as never; + mocks.loadBundledManifestRegistry.mockReturnValue( + createRegistry([ + unreadableRecord, + createPluginRecord({ + id: "healthy-bundled", + origin: "bundled", + configContracts: { + secretInputs: { + paths: [{ path: "healthy.secret", expected: "string" }], + }, + }, + }), + ]), + ); + + expect( + resolvePluginConfigContractsById({ + pluginIds: ["healthy-bundled"], + }), + ).toEqual( + new Map([ + [ + "healthy-bundled", + { + origin: "bundled", + configContracts: { + secretInputs: { + paths: [{ path: "healthy.secret", expected: "string" }], + }, + }, + }, + ], + ]), + ); + }); + it("can skip bundled metadata fallback for registry-scoped callers", () => { expect( resolvePluginConfigContractsById({ diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 412b9370fa587a..d52bb93567af92 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -1,7 +1,7 @@ import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; import type { PluginManifestConfigContracts } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; @@ -15,6 +15,30 @@ export type PluginConfigContractMetadata = { configContracts: PluginManifestConfigContracts; }; +type PluginConfigContractRecordRead = + | { + ok: true; + id: string; + origin: PluginOrigin; + configContracts?: PluginManifestConfigContracts; + } + | { ok: false }; + +function readPluginConfigContractRecord( + plugin: PluginManifestRegistry["plugins"][number], +): PluginConfigContractRecordRead { + try { + return { + ok: true, + id: plugin.id, + origin: plugin.origin, + configContracts: plugin.configContracts, + }; + } catch { + return { ok: false }; + } +} + export function resolvePluginConfigContractsById(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -54,7 +78,11 @@ export function resolvePluginConfigContractsById(params: { diagnostics: discovery.diagnostics, }); for (const plugin of registry.plugins) { - bundledContractFallbacks.set(plugin.id, plugin.configContracts); + const record = readPluginConfigContractRecord(plugin); + if (!record.ok) { + continue; + } + bundledContractFallbacks.set(record.id, record.configContracts); } if (!bundledContractFallbacks.has(pluginId)) { bundledContractFallbacks.set(pluginId, undefined); @@ -70,16 +98,17 @@ export function resolvePluginConfigContractsById(params: { includeDisabled: true, }); for (const plugin of registry.plugins) { - if (!pluginIds.includes(plugin.id)) { + const record = readPluginConfigContractRecord(plugin); + if (!record.ok || !pluginIds.includes(record.id)) { continue; } - resolvedPluginOrigins.set(plugin.id, plugin.origin); - if (!plugin.configContracts) { + resolvedPluginOrigins.set(record.id, record.origin); + if (!record.configContracts) { continue; } - matches.set(plugin.id, { - origin: plugin.origin, - configContracts: plugin.configContracts, + matches.set(record.id, { + origin: record.origin, + configContracts: record.configContracts, }); }