From dfac36ee011c466522316afe71ce699b1023837d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 00:59:44 -0700 Subject: [PATCH] feat(plugins): add cold installed index owner APIs --- src/commands/channel-setup/discovery.test.ts | 15 +++++ src/commands/channel-setup/discovery.ts | 18 +++-- .../models/list.provider-catalog.test.ts | 64 +++++++++--------- src/commands/models/list.provider-catalog.ts | 44 ++++++++----- src/plugins/installed-plugin-index.test.ts | 66 +++++++++++++++++++ src/plugins/installed-plugin-index.ts | 61 +++++++++++++++++ src/plugins/provider-discovery.ts | 14 ++-- 7 files changed, 221 insertions(+), 61 deletions(-) diff --git a/src/commands/channel-setup/discovery.test.ts b/src/commands/channel-setup/discovery.test.ts index f85f61b0fb2..13eb61fa2cd 100644 --- a/src/commands/channel-setup/discovery.test.ts +++ b/src/commands/channel-setup/discovery.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginAutoEnableResult } from "../../config/plugin-auto-enable.js"; const loadInstalledPluginIndex = vi.hoisted(() => vi.fn()); +const listInstalledPluginContributionIds = vi.hoisted(() => + vi.fn((_index?: unknown, _contribution?: unknown, _options?: unknown): string[] => []), +); const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((): unknown[] => [])); const listChatChannels = vi.hoisted(() => vi.fn((): Array> => [])); const applyPluginAutoEnable = vi.hoisted(() => @@ -16,6 +19,8 @@ const applyPluginAutoEnable = vi.hoisted(() => vi.mock("../../plugins/installed-plugin-index.js", () => ({ loadInstalledPluginIndex: (...args: unknown[]) => loadInstalledPluginIndex(...args), + listInstalledPluginContributionIds: (index: unknown, contribution: unknown, options?: unknown) => + listInstalledPluginContributionIds(index, contribution, options), })); vi.mock("../../config/plugin-auto-enable.js", () => ({ @@ -39,6 +44,7 @@ describe("listManifestInstalledChannelIds", () => { plugins: [], diagnostics: [], }); + listInstalledPluginContributionIds.mockReset().mockReturnValue([]); listChannelPluginCatalogEntries.mockReset().mockReturnValue([]); listChatChannels.mockReset().mockReturnValue([]); applyPluginAutoEnable.mockReset().mockImplementation(({ config }) => ({ @@ -65,6 +71,7 @@ describe("listManifestInstalledChannelIds", () => { plugins: [{ pluginId: "slack", contributions: { channels: ["slack"] } }], diagnostics: [], }); + listInstalledPluginContributionIds.mockReturnValue(["slack"]); const installedIds = listManifestInstalledChannelIds({ cfg: {} as never, @@ -81,6 +88,14 @@ describe("listManifestInstalledChannelIds", () => { workspaceDir: "/tmp/workspace", env: { OPENCLAW_HOME: "/tmp/home" }, }); + expect(listInstalledPluginContributionIds).toHaveBeenCalledWith( + { + plugins: [{ pluginId: "slack", contributions: { channels: ["slack"] } }], + diagnostics: [], + }, + "channels", + undefined, + ); expect(installedIds).toEqual(new Set(["slack"])); }); diff --git a/src/commands/channel-setup/discovery.ts b/src/commands/channel-setup/discovery.ts index 684c0f28dfd..df682b8451c 100644 --- a/src/commands/channel-setup/discovery.ts +++ b/src/commands/channel-setup/discovery.ts @@ -7,7 +7,10 @@ import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelMeta } from "../../channels/plugins/types.public.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { loadInstalledPluginIndex } from "../../plugins/installed-plugin-index.js"; +import { + listInstalledPluginContributionIds, + loadInstalledPluginIndex, +} from "../../plugins/installed-plugin-index.js"; import type { ChannelChoice } from "../onboard-types.js"; import { listSetupDiscoveryChannelPluginCatalogEntries, @@ -47,12 +50,15 @@ export function listManifestInstalledChannelIds(params: { env: params.env ?? process.env, }).config; const workspaceDir = resolveWorkspaceDir(resolvedConfig, params.workspaceDir); + const index = loadInstalledPluginIndex({ + config: resolvedConfig, + workspaceDir, + env: params.env ?? process.env, + }); return new Set( - loadInstalledPluginIndex({ - config: resolvedConfig, - workspaceDir, - env: params.env ?? process.env, - }).plugins.flatMap((plugin) => plugin.contributions.channels as ChannelChoice[]), + listInstalledPluginContributionIds(index, "channels").map( + (channelId) => channelId as ChannelChoice, + ), ); } diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index 615a7158b87..54e37ab89f4 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -8,7 +8,7 @@ import { const providerDiscoveryMocks = vi.hoisted(() => ({ loadInstalledPluginIndex: vi.fn(), resolveBundledProviderCompatPluginIds: vi.fn(), - resolveInstalledPluginContributions: vi.fn(), + resolveInstalledPluginContributionOwners: vi.fn(), resolveOwningPluginIdsForProvider: vi.fn(), resolvePluginDiscoveryProviders: vi.fn(), resolveProviderContractPluginIdsForProviderAlias: vi.fn(), @@ -16,7 +16,8 @@ const providerDiscoveryMocks = vi.hoisted(() => ({ vi.mock("../../plugins/installed-plugin-index.js", () => ({ loadInstalledPluginIndex: providerDiscoveryMocks.loadInstalledPluginIndex, - resolveInstalledPluginContributions: providerDiscoveryMocks.resolveInstalledPluginContributions, + resolveInstalledPluginContributionOwners: + providerDiscoveryMocks.resolveInstalledPluginContributionOwners, })); vi.mock("../../plugins/providers.js", () => ({ @@ -109,22 +110,6 @@ const catalogOnlyProvider = { const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider]; -function createContributionMaps(params: { - providers?: ReadonlyMap; - cliBackends?: ReadonlyMap; -}) { - return { - providers: params.providers ?? new Map(), - channels: new Map(), - channelConfigs: new Map(), - setupProviders: new Map(), - cliBackends: params.cliBackends ?? new Map(), - modelCatalogProviders: new Map(), - commandAliases: new Map(), - contracts: new Map(), - }; -} - describe("loadProviderCatalogModelsForList", () => { beforeEach(() => { vi.clearAllMocks(); @@ -132,10 +117,15 @@ describe("loadProviderCatalogModelsForList", () => { plugins: [], diagnostics: [], }); - providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValue( - createContributionMaps({ - providers: new Map(defaultProviders.map((provider) => [provider.id, [provider.pluginId]])), - }), + providerDiscoveryMocks.resolveInstalledPluginContributionOwners.mockImplementation( + (_index: unknown, contribution: string, matches: (contributionId: string) => boolean) => { + if (contribution !== "providers") { + return []; + } + return defaultProviders + .filter((provider) => matches(provider.id)) + .map((provider) => provider.pluginId); + }, ); providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([ "chutes", @@ -215,6 +205,24 @@ describe("loadProviderCatalogModelsForList", () => { expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); }); + it("does not fall back to legacy manifest ownership for disabled installed-index owners", async () => { + providerDiscoveryMocks.resolveInstalledPluginContributionOwners + .mockReturnValueOnce([]) + .mockReturnValueOnce([]) + .mockReturnValueOnce(["moonshot"]) + .mockReturnValueOnce([]); + + await expect( + resolveProviderCatalogPluginIdsForFilter({ + cfg: baseParams.cfg, + env: baseParams.env, + providerFilter: "moonshot", + }), + ).resolves.toEqual([]); + + expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); + }); + it("returns an empty catalog when a static provider catalog throws", async () => { providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValueOnce([ { @@ -272,9 +280,7 @@ describe("loadProviderCatalogModelsForList", () => { }); it("does not skip registry for non-bundled static catalog owners", async () => { - providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce( - createContributionMaps({}), - ); + providerDiscoveryMocks.resolveInstalledPluginContributionOwners.mockReturnValueOnce([]); providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce([ "workspace-static-provider", ]); @@ -292,9 +298,7 @@ describe("loadProviderCatalogModelsForList", () => { }); it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => { - providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce( - createContributionMaps({}), - ); + providerDiscoveryMocks.resolveInstalledPluginContributionOwners.mockReturnValueOnce([]); await expect( resolveProviderCatalogPluginIdsForFilter({ @@ -346,9 +350,7 @@ describe("loadProviderCatalogModelsForList", () => { }); it("keeps unknown provider filters eligible for early empty results", async () => { - providerDiscoveryMocks.resolveInstalledPluginContributions.mockReturnValueOnce( - createContributionMaps({}), - ); + providerDiscoveryMocks.resolveInstalledPluginContributionOwners.mockReturnValueOnce([]); await expect( resolveProviderCatalogPluginIdsForFilter({ diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 62d1a9af670..e15af721939 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -5,8 +5,9 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { + type InstalledPluginIndex, loadInstalledPluginIndex, - resolveInstalledPluginContributions, + resolveInstalledPluginContributionOwners, } from "../../plugins/installed-plugin-index.js"; import { groupPluginDiscoveryProvidersByOrder, @@ -35,17 +36,20 @@ function providerMatchesFilter(params: { ].some((providerId) => normalizeProviderId(providerId) === params.providerFilter); } -function collectMatchingContributionPluginIds( - contributions: ReadonlyMap, +function collectMatchingContributionOwners( + index: InstalledPluginIndex, + contribution: "providers" | "cliBackends", providerFilter: string, + options: { includeDisabled?: boolean } = {}, ): string[] { - const pluginIds: string[] = []; - for (const [contributionId, ownerPluginIds] of contributions) { - if (normalizeProviderId(contributionId) === providerFilter) { - pluginIds.push(...ownerPluginIds); - } - } - return [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)); + return [ + ...resolveInstalledPluginContributionOwners( + index, + contribution, + (contributionId) => normalizeProviderId(contributionId) === providerFilter, + options, + ), + ]; } function resolveInstalledIndexPluginIdsForProviderFilter(params: { @@ -57,14 +61,22 @@ function resolveInstalledIndexPluginIdsForProviderFilter(params: { config: params.cfg, env: params.env, }); - const contributions = resolveInstalledPluginContributions(index); const pluginIds = [ - ...collectMatchingContributionPluginIds(contributions.providers, params.providerFilter), - ...collectMatchingContributionPluginIds(contributions.cliBackends, params.providerFilter), + ...collectMatchingContributionOwners(index, "providers", params.providerFilter), + ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter), ]; - return pluginIds.length > 0 - ? [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)) - : undefined; + if (pluginIds.length > 0) { + return [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)); + } + const disabledPluginIds = [ + ...collectMatchingContributionOwners(index, "providers", params.providerFilter, { + includeDisabled: true, + }), + ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, { + includeDisabled: true, + }), + ]; + return disabledPluginIds.length > 0 ? [] : undefined; } export async function resolveProviderCatalogPluginIdsForFilter(params: { diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 42dc2bc0d72..b0fce746da3 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -4,8 +4,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginCandidate } from "./discovery.js"; import { diffInstalledPluginIndexInvalidationReasons, + getInstalledPluginRecord, + isInstalledPluginEnabled, + listEnabledInstalledPluginRecords, + listInstalledPluginContributionIds, + listInstalledPluginRecords, loadInstalledPluginIndex, refreshInstalledPluginIndex, + resolveInstalledPluginContributionOwners, resolveInstalledPluginContributions, } from "./installed-plugin-index.js"; import { recordPluginInstall } from "./installs.js"; @@ -201,6 +207,66 @@ describe("installed plugin index", () => { expect(contributions.contracts.get("tools")).toEqual(["demo"]); }); + it("exposes cold registry records and owners for existing plugins without install ledgers", () => { + const fixture = createRichPluginFixture(); + const index = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + env: hermeticEnv(), + }); + + expect(listInstalledPluginRecords(index).map((plugin) => plugin.pluginId)).toEqual(["demo"]); + expect(listEnabledInstalledPluginRecords(index).map((plugin) => plugin.pluginId)).toEqual([ + "demo", + ]); + const record = getInstalledPluginRecord(index, "demo"); + expect(record).toMatchObject({ + pluginId: "demo", + enabled: true, + }); + 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", () => { + const fixture = createRichPluginFixture(); + const index = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + config: { + plugins: { + entries: { + demo: { + enabled: false, + }, + }, + }, + }, + env: hermeticEnv(), + }); + + expect(listInstalledPluginRecords(index).map((plugin) => plugin.pluginId)).toEqual(["demo"]); + expect(listEnabledInstalledPluginRecords(index)).toEqual([]); + expect(getInstalledPluginRecord(index, "demo")).toMatchObject({ + pluginId: "demo", + enabled: false, + }); + expect(isInstalledPluginEnabled(index, "demo")).toBe(false); + expect(listInstalledPluginContributionIds(index, "providers")).toEqual([]); + expect( + listInstalledPluginContributionIds(index, "providers", { includeDisabled: true }), + ).toEqual(["demo"]); + expect(resolveInstalledPluginContributionOwners(index, "providers", "demo")).toEqual([]); + expect( + resolveInstalledPluginContributionOwners(index, "providers", "demo", { + includeDisabled: true, + }), + ).toEqual(["demo"]); + }); + it("records the config install ledger separately from package install intent", () => { const fixture = createRichPluginFixture(); diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 30373d51c47..11179978b83 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -116,6 +116,8 @@ export type InstalledPluginContributions = { contracts: ReadonlyMap; }; +export type InstalledPluginContributionKey = keyof InstalledPluginIndexContributions; + export type LoadInstalledPluginIndexParams = { config?: OpenClawConfig; workspaceDir?: string; @@ -448,6 +450,65 @@ export function refreshInstalledPluginIndex( return buildInstalledPluginIndex({ ...params, cache: false, refreshReason: params.reason }); } +export function listInstalledPluginRecords( + index: InstalledPluginIndex, +): readonly InstalledPluginIndexRecord[] { + return index.plugins; +} + +export function listEnabledInstalledPluginRecords( + index: InstalledPluginIndex, +): readonly InstalledPluginIndexRecord[] { + return index.plugins.filter((plugin) => plugin.enabled); +} + +export function getInstalledPluginRecord( + index: InstalledPluginIndex, + pluginId: string, +): InstalledPluginIndexRecord | undefined { + return index.plugins.find((plugin) => plugin.pluginId === pluginId); +} + +export function isInstalledPluginEnabled(index: InstalledPluginIndex, pluginId: string): boolean { + return getInstalledPluginRecord(index, pluginId)?.enabled === true; +} + +function resolveContributionRecordSet( + index: InstalledPluginIndex, + options: { includeDisabled?: boolean }, +): readonly InstalledPluginIndexRecord[] { + return options.includeDisabled ? index.plugins : listEnabledInstalledPluginRecords(index); +} + +export function listInstalledPluginContributionIds( + index: InstalledPluginIndex, + contribution: InstalledPluginContributionKey, + options: { includeDisabled?: boolean } = {}, +): 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 } = {}, +): 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, diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 649b1969773..6f668f15190 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -2,6 +2,7 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { + listInstalledPluginContributionIds, loadInstalledPluginIndex, type InstalledPluginIndex, type LoadInstalledPluginIndexParams, @@ -56,14 +57,11 @@ export function resolveInstalledPluginProviderContributionIds( params: ResolveInstalledPluginProviderContributionIdsParams = {}, ): string[] { const index = params.index ?? loadInstalledPluginIndex(params); - const providerIds: string[] = []; - for (const plugin of index.plugins) { - if (!params.includeDisabled && !plugin.enabled) { - continue; - } - providerIds.push(...plugin.contributions.providers); - } - return sortedValues(providerIds); + return sortedValues( + listInstalledPluginContributionIds(index, "providers", { + includeDisabled: params.includeDisabled, + }), + ); } export async function resolveRuntimePluginDiscoveryProviders(