diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1ef914f1d..406a77bed70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Plugins/startup: move gateway startup plugin planning onto the versioned cold registry index, with postinstall repair for older registry files that predate startup metadata. Thanks @vincentkoc. - Plugins/startup: normalize startup and provider plugin enablement through registry aliases so boot paths do not need the legacy manifest alias scan. Thanks @vincentkoc. - Providers/plugins: resolve provider ownership, provider discovery scopes, and catalog-hook provider ids from the cold plugin registry instead of rescanning manifests on those paths. Thanks @vincentkoc. +- Plugins/registry: keep installed plugin index records focused on install/state/load paths and resolve plugin capabilities from manifests scoped to indexed plugins. Thanks @shakkernerd. - Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc. - Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc. - Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. diff --git a/src/cli/plugins-location-bridges.ts b/src/cli/plugins-location-bridges.ts index 994784e7ae1..a46f0ab9cfc 100644 --- a/src/cli/plugins-location-bridges.ts +++ b/src/cli/plugins-location-bridges.ts @@ -1,9 +1,12 @@ import type { ExternalizedBundledPluginBridge } from "../plugins/externalized-bundled-plugins.js"; import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js"; import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "../plugins/manifest-registry-installed.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; function buildBridgeFromPersistedBundledRecord( record: InstalledPluginIndexRecord, + manifest?: PluginManifestRecord, ): ExternalizedBundledPluginBridge | null { // Relocation is derived from the previous persisted registry, not a hardcoded // table. A plugin moving from bundled to npm keeps the same plugin id; the old @@ -20,7 +23,7 @@ function buildBridgeFromPersistedBundledRecord( pluginId: record.pluginId, npmSpec, ...(record.enabledByDefault ? { enabledByDefault: true } : {}), - channelIds: record.contributions.channels, + ...(manifest?.channels.length ? { channelIds: manifest.channels } : {}), }; } @@ -35,8 +38,18 @@ export async function listPersistedBundledPluginLocationBridges(options: { if (!index) { return []; } + const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ + index, + workspaceDir: options.workspaceDir, + env: options.env, + includeDisabled: true, + }); + const manifestByPluginId = new Map(manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])); return index.plugins.flatMap((record) => { - const bridge = buildBridgeFromPersistedBundledRecord(record); + const bridge = buildBridgeFromPersistedBundledRecord( + record, + manifestByPluginId.get(record.pluginId), + ); return bridge ? [bridge] : []; }); } diff --git a/src/commands/channel-setup/discovery.test.ts b/src/commands/channel-setup/discovery.test.ts index 7ec9e51b317..3b69ce9139e 100644 --- a/src/commands/channel-setup/discovery.test.ts +++ b/src/commands/channel-setup/discovery.test.ts @@ -67,7 +67,7 @@ describe("listManifestInstalledChannelIds", () => { }, }); loadPluginRegistrySnapshot.mockReturnValue({ - plugins: [{ pluginId: "slack", contributions: { channels: ["slack"] } }], + plugins: [{ pluginId: "slack" }], diagnostics: [], }); listPluginContributionIds.mockReturnValue(["slack"]); @@ -89,7 +89,7 @@ describe("listManifestInstalledChannelIds", () => { }); expect(listPluginContributionIds).toHaveBeenCalledWith({ index: { - plugins: [{ pluginId: "slack", contributions: { channels: ["slack"] } }], + plugins: [{ pluginId: "slack" }], diagnostics: [], }, contribution: "channels", diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index 292c1e7abab..fc75b650474 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -20,6 +20,8 @@ import { type InstalledPluginIndexRecord, type LoadInstalledPluginIndexParams, } from "../../../plugins/installed-plugin-index.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "../../../plugins/manifest-registry-installed.js"; +import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js"; export const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION"; export const FORCE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION"; @@ -124,6 +126,7 @@ function normalizeRegistryReference(value: unknown): string | undefined { function createMigrationPluginIdNormalizer( index: InstalledPluginIndex, + manifests: readonly PluginManifestRecord[], ): (pluginId: string) => string { const aliases = new Map(); for (const plugin of index.plugins) { @@ -132,16 +135,25 @@ function createMigrationPluginIdNormalizer( continue; } aliases.set(pluginId, plugin.pluginId); + } + for (const plugin of manifests) { + const pluginId = normalizeRegistryReference(plugin.id); + if (!pluginId) { + continue; + } + aliases.set(pluginId, plugin.id); for (const alias of [ - ...plugin.contributions.providers, - ...plugin.contributions.channels, - ...plugin.contributions.setupProviders, - ...plugin.contributions.cliBackends, - ...plugin.contributions.modelCatalogProviders, + ...plugin.providers, + ...plugin.channels, + ...(plugin.setup?.providers?.map((provider) => provider.id) ?? []), + ...plugin.cliBackends, + ...(plugin.setup?.cliBackends ?? []), + ...Object.keys(plugin.modelCatalog?.providers ?? {}), + ...(plugin.legacyPluginIds ?? []), ]) { const normalizedAlias = normalizeRegistryReference(alias); if (normalizedAlias && !aliases.has(normalizedAlias)) { - aliases.set(normalizedAlias, plugin.pluginId); + aliases.set(normalizedAlias, plugin.id); } } } @@ -193,8 +205,21 @@ export function listMigrationRelevantPluginRecords(params: { index: InstalledPluginIndex; config: OpenClawConfig; installRecords: Record; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; }): readonly InstalledPluginIndexRecord[] { - const normalizePluginId = createMigrationPluginIdNormalizer(params.index); + const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ + index: params.index, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeDisabled: true, + }); + const manifestByPluginId = new Map(manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])); + const normalizePluginId = createMigrationPluginIdNormalizer( + params.index, + manifestRegistry.plugins, + ); const referencedPluginIds = new Set(); const installedPluginIds = new Set(); @@ -226,20 +251,21 @@ export function listMigrationRelevantPluginRecords(params: { if (plugin.origin !== "bundled") { return true; } - if (plugin.enabledByDefault && plugin.contributions.providers.length > 0) { + const manifest = manifestByPluginId.get(plugin.pluginId); + if (plugin.enabledByDefault && (manifest?.providers.length ?? 0) > 0) { return true; } if (installedPluginIds.has(plugin.pluginId) || referencedPluginIds.has(plugin.pluginId)) { return true; } if ( - plugin.contributions.channels.some((channelId) => + (manifest?.channels ?? []).some((channelId) => configuredChannelIds.has(normalizeRegistryReference(channelId) ?? ""), ) ) { return true; } - return plugin.contributions.providers.some((providerId) => + return (manifest?.providers ?? []).some((providerId) => configuredModelProviderIds.has(normalizeProviderId(providerId)), ); }); @@ -282,6 +308,8 @@ export async function migratePluginRegistryForInstall( index: candidateIndex, config, installRecords, + workspaceDir: params.workspaceDir, + env: params.env, }), }; await writePersistedInstalledPluginIndex(current, params); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 285e37e220d..ff6aef449ba 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -50,6 +50,17 @@ import { resolveGatewayStartupPluginIds, } from "./channel-plugin-ids.js"; +function withManifestLoadPaths(plugin: T): T { + return { + rootDir: `/tmp/plugins/${plugin.id}`, + source: `/tmp/plugins/${plugin.id}/index.ts`, + manifestPath: `/tmp/plugins/${plugin.id}/openclaw.plugin.json`, + skills: [], + hooks: [], + ...plugin, + }; +} + function createManifestRegistryFixture() { return { plugins: [ @@ -185,7 +196,7 @@ function createManifestRegistryFixture() { providers: [], cliBackends: [], }, - ], + ].map(withManifestLoadPaths), diagnostics: [], }; } @@ -205,7 +216,7 @@ function createManifestRegistryFixtureWithWorkspaceDemoChannel() { providers: [], cliBackends: [], }, - ], + ].map(withManifestLoadPaths), }; } diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index cf8257b2164..dc0081cb9b0 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -11,6 +11,8 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { hasExplicitChannelConfig } from "./channel-presence-policy.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; +import type { PluginManifestRegistry } from "./manifest-registry.js"; import { createPluginRegistryIdNormalizer, loadPluginRegistrySnapshot, @@ -93,13 +95,21 @@ function shouldConsiderForGatewayStartup(params: { function hasConfiguredStartupChannel(params: { plugin: InstalledPluginIndexRecord; + manifestRegistry: PluginManifestRegistry; configuredChannelIds: ReadonlySet; }): boolean { - return params.plugin.contributions.channels.some((channelId) => + return listManifestChannelIds(params.manifestRegistry, params.plugin.pluginId).some((channelId) => params.configuredChannelIds.has(channelId), ); } +function listManifestChannelIds( + manifestRegistry: PluginManifestRegistry, + pluginId: string, +): readonly string[] { + return manifestRegistry.plugins.find((plugin) => plugin.id === pluginId)?.channels ?? []; +} + function canStartConfiguredChannelPlugin(params: { plugin: InstalledPluginIndexRecord; config: OpenClawConfig; @@ -108,6 +118,7 @@ function canStartConfiguredChannelPlugin(params: { plugins: ReturnType; rootConfig?: OpenClawConfig; }; + manifestRegistry: PluginManifestRegistry; }): boolean { if (!params.pluginsConfig.enabled) { return false; @@ -120,7 +131,7 @@ function canStartConfiguredChannelPlugin(params: { } const explicitBundledChannelConfig = params.plugin.origin === "bundled" && - params.plugin.contributions.channels.some((channelId) => + listManifestChannelIds(params.manifestRegistry, params.plugin.pluginId).some((channelId) => hasExplicitChannelConfig({ config: params.activationSource.rootConfig ?? params.config, channelId, @@ -157,9 +168,16 @@ export function resolveChannelPluginIds(params: { workspaceDir: params.workspaceDir, env: params.env, }); - return index.plugins - .filter((plugin) => plugin.contributions.channels.length > 0) - .map((plugin) => plugin.pluginId); + const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ + index, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeDisabled: true, + }); + return manifestRegistry.plugins + .filter((plugin) => plugin.channels.length > 0) + .map((plugin) => plugin.id); } export function resolveConfiguredDeferredChannelPluginIds(params: { @@ -177,6 +195,13 @@ export function resolveConfiguredDeferredChannelPluginIds(params: { env: params.env, }); const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, index); + const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ + index, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeDisabled: true, + }); const activationSource = { plugins: pluginsConfig, rootConfig: params.config, @@ -184,13 +209,14 @@ export function resolveConfiguredDeferredChannelPluginIds(params: { return index.plugins .filter( (plugin) => - hasConfiguredStartupChannel({ plugin, configuredChannelIds }) && + hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds }) && plugin.startup.deferConfiguredChannelFullLoadUntilAfterListen && canStartConfiguredChannelPlugin({ plugin, config: params.config, pluginsConfig, activationSource, + manifestRegistry, }), ) .map((plugin) => plugin.pluginId); @@ -209,6 +235,13 @@ export function resolveGatewayStartupPluginIds(params: { env: params.env, }); const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, index); + const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ + index, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeDisabled: true, + }); // Startup must classify allowlist exceptions against the raw config snapshot, // not the auto-enabled effective snapshot, or configured-only channels can be // misclassified as explicit enablement. @@ -227,12 +260,13 @@ export function resolveGatewayStartupPluginIds(params: { ); return index.plugins .filter((plugin) => { - if (hasConfiguredStartupChannel({ plugin, configuredChannelIds })) { + if (hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds })) { return canStartConfiguredChannelPlugin({ plugin, config: params.config, pluginsConfig, activationSource, + manifestRegistry, }); } if ( diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index 617f539d594..e4bfb175792 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -39,16 +39,6 @@ function createIndex(overrides: Partial = {}): InstalledPl rootDir: "/plugins/demo", origin: "global", enabled: true, - contributions: { - providers: ["demo"], - channels: ["demo-chat"], - channelConfigs: ["demo-chat"], - setupProviders: [], - cliBackends: [], - modelCatalogProviders: [], - commandAliases: [], - contracts: [], - }, startup: { sidecar: false, memory: false, @@ -221,7 +211,6 @@ describe("installed plugin index persistence", () => { plugins: [ expect.objectContaining({ pluginId: "demo", - contributions: expect.objectContaining({ providers: ["demo", "demo-next"] }), }), ], }, diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 930f79c7bfb..4d27fb719c0 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -34,27 +34,14 @@ export type InstalledPluginIndexStoreInspection = { current: InstalledPluginIndex; }; -const ContributionArraySchema = z.array(z.string()); - -const InstalledPluginIndexContributionsSchema = z - .object({ - providers: ContributionArraySchema, - channels: ContributionArraySchema, - channelConfigs: ContributionArraySchema, - setupProviders: ContributionArraySchema, - cliBackends: ContributionArraySchema, - modelCatalogProviders: ContributionArraySchema, - commandAliases: ContributionArraySchema, - contracts: ContributionArraySchema, - }) - .passthrough(); +const StringArraySchema = z.array(z.string()); const InstalledPluginIndexStartupSchema = z .object({ sidecar: z.boolean(), memory: z.boolean(), deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(), - agentHarnesses: ContributionArraySchema, + agentHarnesses: StringArraySchema, }) .passthrough(); @@ -68,6 +55,8 @@ const InstalledPluginIndexRecordSchema = z packageInstall: z.unknown().optional(), manifestPath: z.string(), manifestHash: z.string(), + source: z.string().optional(), + setupSource: z.string().optional(), packageJson: z .object({ path: z.string(), @@ -78,7 +67,6 @@ const InstalledPluginIndexRecordSchema = z origin: z.string(), enabled: z.boolean(), enabledByDefault: z.boolean().optional(), - contributions: InstalledPluginIndexContributionsSchema, startup: InstalledPluginIndexStartupSchema, compat: z.array(z.string()), }) diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 5584d30d0f6..7ab34a4ffc9 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -11,12 +11,9 @@ import { getInstalledPluginRecord, isInstalledPluginEnabled, listEnabledInstalledPluginRecords, - listInstalledPluginContributionIds, listInstalledPluginRecords, loadInstalledPluginIndex, refreshInstalledPluginIndex, - resolveInstalledPluginContributionOwners, - resolveInstalledPluginContributions, } from "./installed-plugin-index.js"; import { recordPluginInstall } from "./installs.js"; import type { OpenClawPackageManifest } from "./manifest.js"; @@ -168,6 +165,7 @@ describe("installed plugin index", () => { packageVersion: "1.2.3", origin: "global", rootDir: fixture.rootDir, + source: path.join(fixture.rootDir, "index.ts"), enabled: true, packageInstall: { defaultChoice: "npm", @@ -182,16 +180,6 @@ describe("installed plugin index", () => { }, warnings: [], }, - contributions: { - providers: ["demo"], - channels: ["demo-chat"], - channelConfigs: ["demo-chat"], - setupProviders: ["demo"], - cliBackends: ["demo-cli", "setup-cli"], - modelCatalogProviders: ["demo"], - commandAliases: ["demo-command"], - contracts: ["tools"], - }, compat: [ "activation-channel-hint", "activation-provider-hint", @@ -208,11 +196,6 @@ describe("installed plugin index", () => { }); expect(index.plugins[0]?.installRecord).toBeUndefined(); expect(index.plugins[0]?.installRecordHash).toBeUndefined(); - - const contributions = resolveInstalledPluginContributions(index); - expect(contributions.providers.get("demo")).toEqual(["demo"]); - expect(contributions.channels.get("demo-chat")).toEqual(["demo"]); - expect(contributions.contracts.get("tools")).toEqual(["demo"]); }); it("keeps packageJson paths root-relative when packageDir is reached through a symlink", () => { @@ -242,7 +225,7 @@ describe("installed plugin index", () => { }); }); - it("exposes cold registry records and owners for existing plugins without plugin indexs", () => { + it("exposes cold registry records for existing plugins without plugin runtimes", () => { const fixture = createRichPluginFixture(); const index = loadInstalledPluginIndex({ candidates: [fixture.candidate], @@ -260,11 +243,6 @@ describe("installed plugin index", () => { }); expect(record?.installRecord).toBeUndefined(); expect(isInstalledPluginEnabled(index, "demo")).toBe(true); - expect(listInstalledPluginContributionIds(index, "providers")).toEqual(["demo"]); - expect(resolveInstalledPluginContributionOwners(index, "providers", "demo")).toEqual(["demo"]); - expect(resolveInstalledPluginContributionOwners(index, "channels", "demo-chat")).toEqual([ - "demo", - ]); }); it("keeps disabled plugins in inventory while excluding them from cold owner resolution", () => { @@ -299,18 +277,6 @@ describe("installed plugin index", () => { enabled: false, }); expect(isInstalledPluginEnabled(index, "demo", config)).toBe(false); - expect(listInstalledPluginContributionIds(index, "providers", { config })).toEqual([]); - expect( - listInstalledPluginContributionIds(index, "providers", { includeDisabled: true }), - ).toEqual(["demo"]); - expect( - resolveInstalledPluginContributionOwners(index, "providers", "demo", { config }), - ).toEqual([]); - expect( - resolveInstalledPluginContributionOwners(index, "providers", "demo", { - includeDisabled: true, - }), - ).toEqual(["demo"]); }); it("uses runtime plugin id normalization for legacy enablement aliases", () => { @@ -735,7 +701,6 @@ describe("installed plugin index", () => { }), ).toBe(false); expect(index.plugins[0]?.enabled).toBe(false); - expect(index.plugins[0]?.contributions.providers).toEqual(["demo"]); }); it("tracks refresh reason without using the manifest cache", () => { @@ -793,13 +758,11 @@ describe("installed plugin index", () => { env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }), }), compatRegistryVersion: "different-compat-registry", - migrationVersion: 2 as 1, }; expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([ "compat-registry-changed", "host-contract-changed", - "migration", "source-changed", "stale-manifest", "stale-package", diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 5883949359b..0b4e39c6aae 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -11,7 +11,6 @@ import { describePluginInstallSource, type PluginInstallSourceInfo, } from "./install-source-info.js"; -import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; import { loadPluginManifestRegistry, type PluginManifestRecord, @@ -37,17 +36,6 @@ export type InstalledPluginIndexRefreshReason = | "compat-registry-changed" | "manual"; -export type InstalledPluginIndexContributions = { - providers: readonly string[]; - channels: readonly string[]; - channelConfigs: readonly string[]; - setupProviders: readonly string[]; - cliBackends: readonly string[]; - modelCatalogProviders: readonly string[]; - commandAliases: readonly string[]; - contracts: readonly string[]; -}; - export type InstalledPluginStartupInfo = { sidecar: boolean; memory: boolean; @@ -96,6 +84,8 @@ export type InstalledPluginIndexRecord = { packageInstall?: PluginInstallSourceInfo; manifestPath: string; manifestHash: string; + source?: string; + setupSource?: string; packageJson?: { path: string; hash: string; @@ -104,7 +94,6 @@ export type InstalledPluginIndexRecord = { origin: PluginManifestRecord["origin"]; enabled: boolean; enabledByDefault?: boolean; - contributions: InstalledPluginIndexContributions; startup: InstalledPluginStartupInfo; compat: readonly PluginCompatCode[]; }; @@ -123,19 +112,6 @@ export type InstalledPluginIndex = { diagnostics: readonly PluginDiagnostic[]; }; -export type InstalledPluginContributions = { - providers: ReadonlyMap; - channels: ReadonlyMap; - channelConfigs: ReadonlyMap; - setupProviders: ReadonlyMap; - cliBackends: ReadonlyMap; - modelCatalogProviders: ReadonlyMap; - commandAliases: ReadonlyMap; - contracts: ReadonlyMap; -}; - -export type InstalledPluginContributionKey = keyof InstalledPluginIndexContributions; - export type LoadInstalledPluginIndexParams = { config?: OpenClawConfig; workspaceDir?: string; @@ -193,28 +169,6 @@ function sortUnique(values: readonly string[] | undefined): readonly string[] { ); } -function collectObjectKeys(value: Record | undefined): readonly string[] { - return sortUnique(value ? Object.keys(value) : []); -} - -function collectCommandAliasNames( - aliases: readonly PluginManifestCommandAlias[] | undefined, -): readonly string[] { - return sortUnique(aliases?.map((alias) => alias.name) ?? []); -} - -function collectContractKeys(record: PluginManifestRecord): readonly string[] { - const contracts = record.contracts; - if (!contracts) { - return []; - } - return sortUnique( - Object.entries(contracts).flatMap(([key, value]) => - Array.isArray(value) && value.length > 0 ? [key] : [], - ), - ); -} - function hasRuntimeContractSurface(record: PluginManifestRecord): boolean { return Boolean( record.providers.length > 0 || @@ -269,19 +223,6 @@ function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompat return sortUnique(codes) as readonly PluginCompatCode[]; } -function buildContributions(record: PluginManifestRecord): InstalledPluginIndexContributions { - return { - providers: sortUnique(record.providers), - channels: sortUnique(record.channels), - channelConfigs: collectObjectKeys(record.channelConfigs), - setupProviders: sortUnique(record.setup?.providers?.map((provider) => provider.id) ?? []), - cliBackends: sortUnique([...(record.cliBackends ?? []), ...(record.setup?.cliBackends ?? [])]), - modelCatalogProviders: collectObjectKeys(record.modelCatalog?.providers), - commandAliases: collectCommandAliasNames(record.commandAliases), - contracts: collectContractKeys(record), - }; -} - function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined { if (!candidate?.packageDir) { return undefined; @@ -568,16 +509,19 @@ function buildInstalledPluginIndex( pluginId: record.id, manifestPath: record.manifestPath, manifestHash, + source: record.source, rootDir: record.rootDir, origin: record.origin, enabled, - contributions: buildContributions(record), startup: buildStartupInfo(record), compat: collectCompatCodes(record), }; if (record.enabledByDefault === true) { indexRecord.enabledByDefault = true; } + if (record.setupSource) { + indexRecord.setupSource = record.setupSource; + } if (candidate?.packageName) { indexRecord.packageName = candidate.packageName; } @@ -678,118 +622,6 @@ export function isInstalledPluginEnabled( }).enabled; } -function resolveContributionRecordSet( - index: InstalledPluginIndex, - options: { includeDisabled?: boolean; config?: OpenClawConfig }, -): readonly InstalledPluginIndexRecord[] { - return options.includeDisabled - ? index.plugins - : listEnabledInstalledPluginRecords(index, options.config); -} - -export function listInstalledPluginContributionIds( - index: InstalledPluginIndex, - contribution: InstalledPluginContributionKey, - options: { includeDisabled?: boolean; config?: OpenClawConfig } = {}, -): readonly string[] { - return sortUnique( - resolveContributionRecordSet(index, options).flatMap( - (plugin) => plugin.contributions[contribution], - ), - ); -} - -export function resolveInstalledPluginContributionOwners( - index: InstalledPluginIndex, - contribution: InstalledPluginContributionKey, - matches: string | ((contributionId: string) => boolean), - options: { includeDisabled?: boolean; config?: OpenClawConfig } = {}, -): readonly string[] { - const matcher = - typeof matches === "string" ? (contributionId: string) => contributionId === matches : matches; - const owners: string[] = []; - for (const plugin of resolveContributionRecordSet(index, options)) { - if (plugin.contributions[contribution].some(matcher)) { - owners.push(plugin.pluginId); - } - } - return sortUnique(owners); -} - -function addContribution( - target: Map, - contributionId: string, - pluginId: string, -): void { - const existing = target.get(contributionId); - if (existing) { - existing.push(pluginId); - } else { - target.set(contributionId, [pluginId]); - } -} - -function freezeContributionMap( - source: Map, -): ReadonlyMap { - const frozen = new Map(); - for (const [key, pluginIds] of source) { - frozen.set(key, sortUnique(pluginIds)); - } - return frozen; -} - -export function resolveInstalledPluginContributions( - index: InstalledPluginIndex, -): InstalledPluginContributions { - const providers = new Map(); - const channels = new Map(); - const channelConfigs = new Map(); - const setupProviders = new Map(); - const cliBackends = new Map(); - const modelCatalogProviders = new Map(); - const commandAliases = new Map(); - const contracts = new Map(); - - for (const plugin of index.plugins) { - for (const provider of plugin.contributions.providers) { - addContribution(providers, provider, plugin.pluginId); - } - for (const channel of plugin.contributions.channels) { - addContribution(channels, channel, plugin.pluginId); - } - for (const channelConfig of plugin.contributions.channelConfigs) { - addContribution(channelConfigs, channelConfig, plugin.pluginId); - } - for (const setupProvider of plugin.contributions.setupProviders) { - addContribution(setupProviders, setupProvider, plugin.pluginId); - } - for (const cliBackend of plugin.contributions.cliBackends) { - addContribution(cliBackends, cliBackend, plugin.pluginId); - } - for (const modelCatalogProvider of plugin.contributions.modelCatalogProviders) { - addContribution(modelCatalogProviders, modelCatalogProvider, plugin.pluginId); - } - for (const commandAlias of plugin.contributions.commandAliases) { - addContribution(commandAliases, commandAlias, plugin.pluginId); - } - for (const contract of plugin.contributions.contracts) { - addContribution(contracts, contract, plugin.pluginId); - } - } - - return { - providers: freezeContributionMap(providers), - channels: freezeContributionMap(channels), - channelConfigs: freezeContributionMap(channelConfigs), - setupProviders: freezeContributionMap(setupProviders), - cliBackends: freezeContributionMap(cliBackends), - modelCatalogProviders: freezeContributionMap(modelCatalogProviders), - commandAliases: freezeContributionMap(commandAliases), - contracts: freezeContributionMap(contracts), - }; -} - export function diffInstalledPluginIndexInvalidationReasons( previous: InstalledPluginIndex, current: InstalledPluginIndex, diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts new file mode 100644 index 00000000000..df73caa20d6 --- /dev/null +++ b/src/plugins/manifest-registry-installed.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir() { + return makeTrackedTempDir("openclaw-installed-manifest-registry", tempDirs); +} + +function writePlugin(rootDir: string, pluginId: string, modelPrefix: string) { + fs.writeFileSync( + path.join(rootDir, "index.ts"), + "throw new Error('runtime entry should not load while reading manifests');\n", + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: pluginId, + configSchema: { type: "object" }, + providers: [pluginId], + modelSupport: { + modelPrefixes: [modelPrefix], + }, + }), + "utf8", + ); +} + +function createIndex(rootDir: string): InstalledPluginIndex { + return { + version: 1, + hostContractVersion: "2026.4.25", + compatRegistryVersion: "compat-v1", + migrationVersion: 1, + policyHash: "policy-v1", + generatedAtMs: 1777118400000, + installRecords: {}, + plugins: [ + { + pluginId: "installed", + manifestPath: path.join(rootDir, "openclaw.plugin.json"), + manifestHash: "manifest-hash", + source: path.join(rootDir, "index.ts"), + rootDir, + origin: "global", + enabled: true, + startup: { + sidecar: false, + memory: false, + deferConfiguredChannelFullLoadUntilAfterListen: false, + agentHarnesses: [], + }, + compat: [], + }, + ], + diagnostics: [], + }; +} + +describe("loadPluginManifestRegistryForInstalledIndex", () => { + it("loads manifest metadata only for plugins present in the installed index", () => { + const installedRoot = makeTempDir(); + const unrelatedRoot = makeTempDir(); + writePlugin(installedRoot, "installed", "installed-"); + writePlugin(unrelatedRoot, "unrelated", "unrelated-"); + + const registry = loadPluginManifestRegistryForInstalledIndex({ + index: createIndex(installedRoot), + env: { + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }, + includeDisabled: true, + }); + + expect(registry.plugins.map((plugin) => plugin.id)).toEqual(["installed"]); + expect(registry.plugins[0]?.modelSupport).toEqual({ + modelPrefixes: ["installed-"], + }); + }); +}); diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts new file mode 100644 index 00000000000..c24182fe2d1 --- /dev/null +++ b/src/plugins/manifest-registry-installed.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginCandidate } from "./discovery.js"; +import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js"; +import { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index.js"; +import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; +import { DEFAULT_PLUGIN_ENTRY_CANDIDATES } from "./manifest.js"; + +function resolveFallbackPluginSource(record: InstalledPluginIndexRecord): string { + for (const entry of DEFAULT_PLUGIN_ENTRY_CANDIDATES) { + const candidate = path.join(record.rootDir, entry); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return path.join(record.rootDir, DEFAULT_PLUGIN_ENTRY_CANDIDATES[0]); +} + +function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate { + return { + idHint: record.pluginId, + source: record.source ?? resolveFallbackPluginSource(record), + ...(record.setupSource ? { setupSource: record.setupSource } : {}), + rootDir: record.rootDir, + origin: record.origin, + ...(record.packageName ? { packageName: record.packageName } : {}), + ...(record.packageVersion ? { packageVersion: record.packageVersion } : {}), + packageDir: record.rootDir, + }; +} + +export function loadPluginManifestRegistryForInstalledIndex(params: { + index: InstalledPluginIndex; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; + includeDisabled?: boolean; +}): PluginManifestRegistry { + if (params.pluginIds && params.pluginIds.length === 0) { + return { plugins: [], diagnostics: [] }; + } + const pluginIdSet = params.pluginIds?.length ? new Set(params.pluginIds) : null; + const candidates = params.index.plugins + .filter((plugin) => params.includeDisabled || plugin.enabled) + .filter((plugin) => !pluginIdSet || pluginIdSet.has(plugin.pluginId)) + .map(toPluginCandidate); + return loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + cache: false, + candidates, + installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(params.index), + }); +} diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 0a29750a1a9..edfaca04c74 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -117,16 +117,6 @@ function createIndex( rootDir: `/plugins/${pluginId}`, origin: "global", enabled: true, - contributions: { - providers: [pluginId], - channels: [], - channelConfigs: [], - setupProviders: [], - cliBackends: [], - modelCatalogProviders: [], - commandAliases: [], - contracts: [], - }, startup: { sidecar: false, memory: false, @@ -210,17 +200,25 @@ describe("plugin registry facade", () => { }); it("normalizes plugin config ids through registry contribution aliases", () => { - const baseIndex = createIndex("openai"); - const plugin = baseIndex.plugins[0]; + const rootDir = makeTempDir(); + fs.writeFileSync(path.join(rootDir, "index.ts"), "", "utf8"); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "openai", + configSchema: { type: "object" }, + providers: ["openai", "openai-codex"], + channels: ["openai-chat"], + }), + "utf8", + ); const index = createIndex("openai", { plugins: [ { - ...plugin, - contributions: { - ...plugin.contributions, - providers: ["openai", "openai-codex"], - channels: ["openai-chat"], - }, + ...createIndex("openai").plugins[0], + manifestPath: path.join(rootDir, "openclaw.plugin.json"), + source: path.join(rootDir, "index.ts"), + rootDir, }, ], }); diff --git a/src/plugins/plugin-registry.ts b/src/plugins/plugin-registry.ts index 1b1313daedf..592c4945f55 100644 --- a/src/plugins/plugin-registry.ts +++ b/src/plugins/plugin-registry.ts @@ -13,17 +13,16 @@ import { getInstalledPluginRecord, extractPluginInstallRecordsFromInstalledPluginIndex, isInstalledPluginEnabled, - listInstalledPluginContributionIds, listInstalledPluginRecords, loadInstalledPluginIndex, - resolveInstalledPluginContributionOwners, resolveInstalledPluginIndexPolicyHash, - type InstalledPluginContributionKey, type InstalledPluginIndex, type InstalledPluginIndexRecord, type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, } from "./installed-plugin-index.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; @@ -66,13 +65,23 @@ export type GetPluginRecordParams = LoadPluginRegistryParams & { pluginId: string; }; +export type PluginRegistryContributionKey = + | "providers" + | "channels" + | "channelConfigs" + | "setupProviders" + | "cliBackends" + | "modelCatalogProviders" + | "commandAliases" + | "contracts"; + export type ResolvePluginContributionOwnersParams = PluginRegistryContributionOptions & { - contribution: InstalledPluginContributionKey; + contribution: PluginRegistryContributionKey; matches: string | ((contributionId: string) => boolean); }; export type ListPluginContributionIdsParams = PluginRegistryContributionOptions & { - contribution: InstalledPluginContributionKey; + contribution: PluginRegistryContributionKey; }; export type ResolveProviderOwnersParams = PluginRegistryContributionOptions & { @@ -103,24 +112,114 @@ function normalizePluginRegistryAliasKey(value: string): string { return normalizePluginRegistryAlias(value).toLowerCase(); } +function sortUnique(values: Iterable): string[] { + return [...new Set([...values].map((value) => value.trim()).filter(Boolean))].toSorted( + (left, right) => left.localeCompare(right), + ); +} + +function collectObjectKeys(value: Record | undefined): readonly string[] { + return value ? Object.keys(value) : []; +} + +function collectContractKeys(plugin: PluginManifestRecord): readonly string[] { + const contracts = plugin.contracts; + if (!contracts) { + return []; + } + return Object.entries(contracts).flatMap(([key, value]) => + Array.isArray(value) && value.length > 0 ? [key] : [], + ); +} + +function listManifestContributionIds( + plugin: PluginManifestRecord, + contribution: PluginRegistryContributionKey, +): readonly string[] { + switch (contribution) { + case "providers": + return plugin.providers; + case "channels": + return plugin.channels; + case "channelConfigs": + return collectObjectKeys(plugin.channelConfigs); + case "setupProviders": + return plugin.setup?.providers?.map((provider) => provider.id) ?? []; + case "cliBackends": + return [...plugin.cliBackends, ...(plugin.setup?.cliBackends ?? [])]; + case "modelCatalogProviders": + return collectObjectKeys(plugin.modelCatalog?.providers); + case "commandAliases": + return plugin.commandAliases?.map((alias) => alias.name) ?? []; + case "contracts": + return collectContractKeys(plugin); + } + return []; +} + +function resolveContributionPluginIds(params: { + index: PluginRegistrySnapshot; + includeDisabled?: boolean; + config?: OpenClawConfig; +}): readonly string[] { + if (params.includeDisabled) { + return params.index.plugins.map((plugin) => plugin.pluginId); + } + return params.index.plugins + .filter((plugin) => isInstalledPluginEnabled(params.index, plugin.pluginId, params.config)) + .map((plugin) => plugin.pluginId); +} + +function loadContributionManifestRegistry( + params: LoadPluginRegistryParams & { + index: PluginRegistrySnapshot; + includeDisabled?: boolean; + }, +): PluginManifestRegistry { + return loadPluginManifestRegistryForInstalledIndex({ + index: params.index, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + pluginIds: resolveContributionPluginIds({ + index: params.index, + includeDisabled: params.includeDisabled, + config: params.config, + }), + includeDisabled: true, + }); +} + export function createPluginRegistryIdNormalizer( index: PluginRegistrySnapshot, ): (pluginId: string) => string { const aliases = new Map(); - for (const plugin of [...index.plugins].toSorted((left, right) => - left.pluginId.localeCompare(right.pluginId), - )) { + for (const plugin of index.plugins) { const pluginId = normalizePluginRegistryAlias(plugin.pluginId); + if (pluginId) { + aliases.set(normalizePluginRegistryAliasKey(pluginId), plugin.pluginId); + } + } + const registry = loadPluginManifestRegistryForInstalledIndex({ + index, + includeDisabled: true, + }); + for (const plugin of [...registry.plugins].toSorted((left, right) => + left.id.localeCompare(right.id), + )) { + const pluginId = normalizePluginRegistryAlias(plugin.id); if (!pluginId) { continue; } - aliases.set(normalizePluginRegistryAliasKey(pluginId), pluginId); + aliases.set(normalizePluginRegistryAliasKey(pluginId), plugin.id); for (const alias of [ - ...plugin.contributions.providers, - ...plugin.contributions.channels, - ...plugin.contributions.setupProviders, - ...plugin.contributions.cliBackends, - ...plugin.contributions.modelCatalogProviders, + plugin.id, + ...listManifestContributionIds(plugin, "providers"), + ...listManifestContributionIds(plugin, "channels"), + ...listManifestContributionIds(plugin, "setupProviders"), + ...listManifestContributionIds(plugin, "cliBackends"), + ...listManifestContributionIds(plugin, "modelCatalogProviders"), + ...(plugin.legacyPluginIds ?? []), ]) { const normalizedAlias = normalizePluginRegistryAlias(alias); const normalizedAliasKey = normalizePluginRegistryAliasKey(alias); @@ -241,23 +340,32 @@ export function isPluginEnabled(params: GetPluginRecordParams): boolean { export function listPluginContributionIds( params: ListPluginContributionIdsParams, ): readonly string[] { - return listInstalledPluginContributionIds(resolveSnapshot(params), params.contribution, { - includeDisabled: params.includeDisabled, - config: params.config, + const index = resolveSnapshot(params); + const registry = loadContributionManifestRegistry({ + ...params, + index, }); + return sortUnique( + registry.plugins.flatMap((plugin) => listManifestContributionIds(plugin, params.contribution)), + ); } export function resolvePluginContributionOwners( params: ResolvePluginContributionOwnersParams, ): readonly string[] { - return resolveInstalledPluginContributionOwners( - resolveSnapshot(params), - params.contribution, - params.matches, - { - includeDisabled: params.includeDisabled, - config: params.config, - }, + const matcher = + typeof params.matches === "string" + ? (contributionId: string) => contributionId === params.matches + : params.matches; + const index = resolveSnapshot(params); + const registry = loadContributionManifestRegistry({ + ...params, + index, + }); + return sortUnique( + registry.plugins.flatMap((plugin) => + listManifestContributionIds(plugin, params.contribution).some(matcher) ? [plugin.id] : [], + ), ); } diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 7440e64e84f..dfff44f5227 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -7,11 +7,8 @@ import { isActivatedManifestOwner, passesManifestOwnerBasePolicy, } from "./manifest-owner-policy.js"; -import { - loadPluginManifestRegistry, - type PluginManifestRecord, - type PluginManifestRegistry, -} from "./manifest-registry.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; +import { type PluginManifestRecord, type PluginManifestRegistry } from "./manifest-registry.js"; import { loadPluginRegistrySnapshot, normalizePluginsConfigWithRegistry, @@ -32,14 +29,6 @@ type ProviderRegistryLoadParams = ProviderManifestLoadParams & { onlyPluginIds?: readonly string[]; }; -function loadProviderManifestRegistry(params: ProviderManifestLoadParams): PluginManifestRegistry { - return loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); -} - function loadProviderRegistrySnapshot(params: ProviderManifestLoadParams): PluginRegistrySnapshot { return loadPluginRegistrySnapshot({ config: params.config, @@ -68,6 +57,19 @@ function listRegistryPluginIds( .toSorted((left, right) => left.localeCompare(right)); } +function resolveProviderSurfacePluginIdSet( + params: ProviderManifestLoadParams & { + registry: PluginRegistrySnapshot; + }, +): ReadonlySet { + return new Set( + resolveManifestRegistry({ + ...params, + includeDisabled: true, + }).plugins.flatMap((plugin) => (plugin.providers.length > 0 ? [plugin.id] : [])), + ); +} + function resolveProviderOwnerPluginIds( params: ProviderRegistryLoadParams & { pluginIds: readonly string[]; @@ -89,10 +91,6 @@ function resolveProviderOwnerPluginIds( ); } -function recordHasProviderSurface(plugin: PluginRegistryRecord): boolean { - return plugin.contributions.providers.length > 0; -} - function resolveEffectiveRegistryPluginActivation(params: { plugin: PluginRegistryRecord; normalizedConfig: NormalizedPluginsConfig; @@ -130,11 +128,12 @@ export function resolveBundledProviderCompatPluginIds(params: { onlyPluginIds?: readonly string[]; }): string[] { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); + const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); return listRegistryPluginIds( registry, (plugin) => plugin.origin === "bundled" && - recordHasProviderSurface(plugin) && + providerSurfacePluginIds.has(plugin.pluginId) && (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.pluginId)), ); } @@ -146,11 +145,12 @@ export function resolveEnabledProviderPluginIds(params: { onlyPluginIds?: readonly string[]; }): string[] { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); + const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds( registry, (plugin) => - recordHasProviderSurface(plugin) && + providerSurfacePluginIds.has(plugin.pluginId) && (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.pluginId)) && resolveEffectiveRegistryPluginActivation({ plugin, @@ -180,15 +180,25 @@ function resolveRegistryManifestContractPluginIds(params: { onlyPluginIds?: readonly string[]; }): string[] { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); - return listRegistryPluginIds(registry, (plugin) => { - if (params.origin && plugin.origin !== params.origin) { - return false; - } - if (onlyPluginIdSet && !onlyPluginIdSet.has(plugin.pluginId)) { - return false; - } - return plugin.contributions.contracts.includes(params.contract); - }); + return resolveManifestRegistry({ + ...params, + registry, + includeDisabled: true, + }) + .plugins.filter((plugin) => { + if (params.origin && plugin.origin !== params.origin) { + return false; + } + if (onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id)) { + return false; + } + return ( + (plugin.contracts?.[params.contract as keyof NonNullable] ?? []) + .length > 0 + ); + }) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); } export function resolveExternalAuthProfileCompatFallbackPluginIds(params: { @@ -203,12 +213,13 @@ export function resolveExternalAuthProfileCompatFallbackPluginIds(params: { const declaredPluginIds = params.declaredPluginIds ?? new Set(resolveExternalAuthProfileProviderPluginIds(params)); const registry = loadProviderRegistrySnapshot(params); + const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds( registry, (plugin) => plugin.origin !== "bundled" && - recordHasProviderSurface(plugin) && + providerSurfacePluginIds.has(plugin.pluginId) && !declaredPluginIds.has(plugin.pluginId) && isProviderPluginEligibleForRuntimeOwnerActivation({ plugin, @@ -226,12 +237,13 @@ export function resolveDiscoveredProviderPluginIds(params: { includeUntrustedWorkspacePlugins?: boolean; }): string[] { const { registry, onlyPluginIdSet } = loadScopedProviderRegistry(params); + const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const shouldFilterUntrustedWorkspacePlugins = params.includeUntrustedWorkspacePlugins === false; const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds(registry, (plugin) => { if ( !( - recordHasProviderSurface(plugin) && + providerSurfacePluginIds.has(plugin.pluginId) && (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.pluginId)) ) ) { @@ -349,8 +361,20 @@ function resolveManifestRegistry(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; manifestRegistry?: PluginManifestRegistry; + registry?: PluginRegistrySnapshot; + includeDisabled?: boolean; }): PluginManifestRegistry { - return params.manifestRegistry ?? loadProviderManifestRegistry(params); + if (params.manifestRegistry) { + return params.manifestRegistry; + } + const registry = params.registry ?? loadProviderRegistrySnapshot(params); + return loadPluginManifestRegistryForInstalledIndex({ + index: registry, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeDisabled: params.includeDisabled, + }); } function stripModelProfileSuffix(value: string): string { @@ -482,6 +506,7 @@ export function resolveOwningPluginIdsForModelRef(params: { workspaceDir?: string; env?: PluginLoadOptions["env"]; manifestRegistry?: PluginManifestRegistry; + registry?: PluginRegistrySnapshot; }): string[] | undefined { const parsed = splitExplicitModelRef(params.model); if (!parsed) { @@ -498,19 +523,25 @@ export function resolveOwningPluginIdsForModelRef(params: { }); } - const registry = resolveManifestRegistry(params); - const matchedByPattern = registry.plugins + const manifestRegistry = resolveManifestRegistry({ + ...params, + includeDisabled: true, + }); + const matchedByPattern = manifestRegistry.plugins .filter((plugin) => resolveModelSupportMatchKind(plugin, parsed.modelId) === "pattern") .map((plugin) => plugin.id); - const preferredPatternPluginIds = resolvePreferredManifestPluginIds(registry, matchedByPattern); + const preferredPatternPluginIds = resolvePreferredManifestPluginIds( + manifestRegistry, + matchedByPattern, + ); if (preferredPatternPluginIds) { return preferredPatternPluginIds; } - const matchedByPrefix = registry.plugins + const matchedByPrefix = manifestRegistry.plugins .filter((plugin) => resolveModelSupportMatchKind(plugin, parsed.modelId) === "prefix") .map((plugin) => plugin.id); - return resolvePreferredManifestPluginIds(registry, matchedByPrefix); + return resolvePreferredManifestPluginIds(manifestRegistry, matchedByPrefix); } export function resolveOwningPluginIdsForModelRefs(params: { @@ -520,7 +551,8 @@ export function resolveOwningPluginIdsForModelRefs(params: { env?: PluginLoadOptions["env"]; manifestRegistry?: PluginManifestRegistry; }): string[] { - const registry = resolveManifestRegistry(params); + const registry = params.manifestRegistry ? undefined : loadProviderRegistrySnapshot(params); + const manifestRegistry = params.manifestRegistry; return dedupeSortedPluginIds( params.models.flatMap( (model) => @@ -529,7 +561,8 @@ export function resolveOwningPluginIdsForModelRefs(params: { config: params.config, workspaceDir: params.workspaceDir, env: params.env, - manifestRegistry: registry, + ...(manifestRegistry ? { manifestRegistry } : {}), + ...(registry ? { registry } : {}), }) ?? [], ), ); @@ -541,12 +574,13 @@ export function resolveNonBundledProviderPluginIds(params: { env?: PluginLoadOptions["env"]; }): string[] { const registry = loadProviderRegistrySnapshot(params); + const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); return listRegistryPluginIds( registry, (plugin) => plugin.origin !== "bundled" && - recordHasProviderSurface(plugin) && + providerSurfacePluginIds.has(plugin.pluginId) && resolveEffectiveRegistryPluginActivation({ plugin, normalizedConfig, @@ -561,11 +595,12 @@ export function resolveCatalogHookProviderPluginIds(params: { env?: PluginLoadOptions["env"]; }): string[] { const registry = loadProviderRegistrySnapshot(params); + const providerSurfacePluginIds = resolveProviderSurfacePluginIdSet({ ...params, registry }); const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); const enabledProviderPluginIds = listRegistryPluginIds( registry, (plugin) => - recordHasProviderSurface(plugin) && + providerSurfacePluginIds.has(plugin.pluginId) && resolveEffectiveRegistryPluginActivation({ plugin, normalizedConfig, diff --git a/src/plugins/setup-registry.runtime.test.ts b/src/plugins/setup-registry.runtime.test.ts index 6dae41cbe8d..f2b8a5e1479 100644 --- a/src/plugins/setup-registry.runtime.test.ts +++ b/src/plugins/setup-registry.runtime.test.ts @@ -1,13 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const loadPluginRegistrySnapshotMock = vi.hoisted(() => vi.fn()); +const loadPluginManifestRegistryForInstalledIndexMock = vi.hoisted(() => vi.fn()); vi.mock("./plugin-registry.js", () => ({ loadPluginRegistrySnapshot: loadPluginRegistrySnapshotMock, })); +vi.mock("./manifest-registry-installed.js", () => ({ + loadPluginManifestRegistryForInstalledIndex: loadPluginManifestRegistryForInstalledIndexMock, +})); afterEach(() => { loadPluginRegistrySnapshotMock.mockReset(); + loadPluginManifestRegistryForInstalledIndexMock.mockReset(); }); describe("setup-registry runtime fallback", () => { @@ -19,25 +24,26 @@ describe("setup-registry runtime fallback", () => { pluginId: "openai", origin: "bundled", enabled: true, - contributions: { - cliBackends: ["Codex-CLI", "legacy-openai-cli"], - }, }, { pluginId: "disabled", origin: "bundled", enabled: false, - contributions: { - cliBackends: ["disabled-cli"], - }, }, { pluginId: "local", origin: "workspace", enabled: true, - contributions: { - cliBackends: ["local-cli"], - }, + }, + ], + }); + loadPluginManifestRegistryForInstalledIndexMock.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "openai", + origin: "bundled", + cliBackends: ["Codex-CLI", "legacy-openai-cli"], }, ], }); @@ -55,6 +61,11 @@ describe("setup-registry runtime fallback", () => { expect(resolvePluginSetupCliBackendRuntime({ backend: "disabled-cli" })).toBeUndefined(); expect(loadPluginRegistrySnapshotMock).toHaveBeenCalledTimes(1); expect(loadPluginRegistrySnapshotMock).toHaveBeenCalledWith({ cache: true }); + expect(loadPluginManifestRegistryForInstalledIndexMock).toHaveBeenCalledWith({ + index: expect.objectContaining({ + plugins: expect.arrayContaining([expect.objectContaining({ pluginId: "openai" })]), + }), + }); }); it("preserves fail-closed setup lookup when the runtime module explicitly declines to resolve", async () => { @@ -65,9 +76,6 @@ describe("setup-registry runtime fallback", () => { pluginId: "openai", origin: "bundled", enabled: true, - contributions: { - cliBackends: ["Codex-CLI", "legacy-openai-cli"], - }, }, ], }); diff --git a/src/plugins/setup-registry.runtime.ts b/src/plugins/setup-registry.runtime.ts index d8c60448396..ea014043ac6 100644 --- a/src/plugins/setup-registry.runtime.ts +++ b/src/plugins/setup-registry.runtime.ts @@ -1,5 +1,6 @@ import { createRequire } from "node:module"; import { normalizeProviderId } from "../agents/provider-id.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; import { loadPluginRegistrySnapshot } from "./plugin-registry.js"; type SetupRegistryRuntimeModule = Pick< @@ -34,20 +35,21 @@ function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] { if (bundledSetupCliBackendsCache) { return bundledSetupCliBackendsCache; } - bundledSetupCliBackendsCache = loadPluginRegistrySnapshot({ cache: true }).plugins.flatMap( - (plugin) => { - if (plugin.origin !== "bundled" || !plugin.enabled) { - return []; - } - return plugin.contributions.cliBackends.map( - (backendId) => - ({ - pluginId: plugin.pluginId, - backend: { id: backendId }, - }) satisfies SetupCliBackendRuntimeEntry, - ); - }, - ); + const index = loadPluginRegistrySnapshot({ cache: true }); + bundledSetupCliBackendsCache = loadPluginManifestRegistryForInstalledIndex({ + index, + }).plugins.flatMap((plugin) => { + if (plugin.origin !== "bundled") { + return []; + } + return [...plugin.cliBackends, ...(plugin.setup?.cliBackends ?? [])].map( + (backendId) => + ({ + pluginId: plugin.id, + backend: { id: backendId }, + }) satisfies SetupCliBackendRuntimeEntry, + ); + }); return bundledSetupCliBackendsCache; } diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 52d80fd57d2..ed74812a0ab 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -18,6 +18,8 @@ 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, @@ -155,6 +157,7 @@ type PluginReportParams = { function buildPluginRecordFromInstalledIndex( plugin: import("./installed-plugin-index.js").InstalledPluginIndexRecord, + manifest?: PluginManifestRecord, ): PluginRecord { return { id: plugin.pluginId, @@ -168,9 +171,9 @@ function buildPluginRecordFromInstalledIndex( status: plugin.enabled ? "loaded" : "disabled", toolNames: [], hookNames: [], - channelIds: [...plugin.contributions.channels], - cliBackendIds: [...plugin.contributions.cliBackends], - providerIds: [...plugin.contributions.providers], + channelIds: [...(manifest?.channels ?? [])], + cliBackendIds: [...(manifest?.cliBackends ?? []), ...(manifest?.setup?.cliBackends ?? [])], + providerIds: [...(manifest?.providers ?? [])], speechProviderIds: [], realtimeTranscriptionProviderIds: [], realtimeVoiceProviderIds: [], @@ -186,7 +189,7 @@ function buildPluginRecordFromInstalledIndex( cliCommands: [], services: [], gatewayDiscoveryServiceIds: [], - commands: [...plugin.contributions.commandAliases], + commands: [...(manifest?.commandAliases?.map((alias) => alias.name) ?? [])], httpRoutes: 0, hookCount: 0, configSchema: false, @@ -203,10 +206,20 @@ 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(buildPluginRecordFromInstalledIndex), + plugins: result.snapshot.plugins.map((plugin) => + buildPluginRecordFromInstalledIndex(plugin, manifestByPluginId.get(plugin.pluginId)), + ), diagnostics: [...result.snapshot.diagnostics], registrySource: result.source, registryDiagnostics: result.diagnostics, diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index 61bdf5ea843..332f159606c 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -122,16 +122,6 @@ describe("security audit install metadata findings", () => { rootDir: path.join(stateDir, "extensions", pluginId), origin: "global" as const, enabled: true, - contributions: { - providers: [], - channels: [], - channelConfigs: [], - setupProviders: [], - cliBackends: [], - modelCatalogProviders: [], - commandAliases: [], - contracts: [], - }, startup: { sidecar: true, memory: false,