feat(plugins): add cold installed index owner APIs

This commit is contained in:
Vincent Koc
2026-04-25 00:59:44 -07:00
parent f6a3b42cfa
commit dfac36ee01
7 changed files with 221 additions and 61 deletions

View File

@@ -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<Record<string, string>> => []));
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"]));
});

View File

@@ -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,
),
);
}

View File

@@ -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<string, readonly string[]>;
cliBackends?: ReadonlyMap<string, readonly string[]>;
}) {
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({

View File

@@ -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<string, readonly string[]>,
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: {

View File

@@ -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();

View File

@@ -116,6 +116,8 @@ export type InstalledPluginContributions = {
contracts: ReadonlyMap<string, readonly string[]>;
};
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<string, string[]>,
contributionId: string,

View File

@@ -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(