mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
feat(plugins): add cold installed index owner APIs
This commit is contained in:
@@ -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"]));
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user