feat(plugins): add plugin registry facade

This commit is contained in:
Vincent Koc
2026-04-25 01:32:29 -07:00
parent 36219b0ffc
commit 3556f8441a
7 changed files with 425 additions and 65 deletions

View File

@@ -1,8 +1,8 @@
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(() =>
const loadPluginRegistrySnapshot = vi.hoisted(() => vi.fn());
const listPluginContributionIds = vi.hoisted(() =>
vi.fn((_index?: unknown, _contribution?: unknown, _options?: unknown): string[] => []),
);
const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((): unknown[] => []));
@@ -17,10 +17,9 @@ 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("../../plugins/plugin-registry.js", () => ({
loadPluginRegistrySnapshot: (...args: unknown[]) => loadPluginRegistrySnapshot(...args),
listPluginContributionIds: (args: unknown) => listPluginContributionIds(args),
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
@@ -40,11 +39,11 @@ import { listManifestInstalledChannelIds, resolveChannelSetupEntries } from "./d
describe("listManifestInstalledChannelIds", () => {
beforeEach(() => {
loadInstalledPluginIndex.mockReset().mockReturnValue({
loadPluginRegistrySnapshot.mockReset().mockReturnValue({
plugins: [],
diagnostics: [],
});
listInstalledPluginContributionIds.mockReset().mockReturnValue([]);
listPluginContributionIds.mockReset().mockReturnValue([]);
listChannelPluginCatalogEntries.mockReset().mockReturnValue([]);
listChatChannels.mockReset().mockReturnValue([]);
applyPluginAutoEnable.mockReset().mockImplementation(({ config }) => ({
@@ -67,11 +66,11 @@ describe("listManifestInstalledChannelIds", () => {
slack: ["slack configured"],
},
});
loadInstalledPluginIndex.mockReturnValue({
loadPluginRegistrySnapshot.mockReturnValue({
plugins: [{ pluginId: "slack", contributions: { channels: ["slack"] } }],
diagnostics: [],
});
listInstalledPluginContributionIds.mockReturnValue(["slack"]);
listPluginContributionIds.mockReturnValue(["slack"]);
const installedIds = listManifestInstalledChannelIds({
cfg: {} as never,
@@ -83,19 +82,18 @@ describe("listManifestInstalledChannelIds", () => {
config: {},
env: { OPENCLAW_HOME: "/tmp/home" },
});
expect(loadInstalledPluginIndex).toHaveBeenCalledWith({
expect(loadPluginRegistrySnapshot).toHaveBeenCalledWith({
config: autoEnabledConfig,
workspaceDir: "/tmp/workspace",
env: { OPENCLAW_HOME: "/tmp/home" },
});
expect(listInstalledPluginContributionIds).toHaveBeenCalledWith(
{
expect(listPluginContributionIds).toHaveBeenCalledWith({
index: {
plugins: [{ pluginId: "slack", contributions: { channels: ["slack"] } }],
diagnostics: [],
},
"channels",
undefined,
);
contribution: "channels",
});
expect(installedIds).toEqual(new Set(["slack"]));
});

View File

@@ -8,9 +8,9 @@ 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 {
listInstalledPluginContributionIds,
loadInstalledPluginIndex,
} from "../../plugins/installed-plugin-index.js";
listPluginContributionIds,
loadPluginRegistrySnapshot,
} from "../../plugins/plugin-registry.js";
import type { ChannelChoice } from "../onboard-types.js";
import {
listSetupDiscoveryChannelPluginCatalogEntries,
@@ -50,13 +50,13 @@ export function listManifestInstalledChannelIds(params: {
env: params.env ?? process.env,
}).config;
const workspaceDir = resolveWorkspaceDir(resolvedConfig, params.workspaceDir);
const index = loadInstalledPluginIndex({
const index = loadPluginRegistrySnapshot({
config: resolvedConfig,
workspaceDir,
env: params.env ?? process.env,
});
return new Set(
listInstalledPluginContributionIds(index, "channels").map(
listPluginContributionIds({ index, contribution: "channels" }).map(
(channelId) => channelId as ChannelChoice,
),
);

View File

@@ -6,18 +6,19 @@ import {
} from "./list.provider-catalog.js";
const providerDiscoveryMocks = vi.hoisted(() => ({
loadInstalledPluginIndex: vi.fn(),
loadPluginRegistrySnapshot: vi.fn(),
resolvePluginContributionOwners: vi.fn(),
resolveProviderOwners: vi.fn(),
resolveBundledProviderCompatPluginIds: vi.fn(),
resolveInstalledPluginContributionOwners: vi.fn(),
resolveOwningPluginIdsForProvider: vi.fn(),
resolvePluginDiscoveryProviders: vi.fn(),
resolveProviderContractPluginIdsForProviderAlias: vi.fn(),
}));
vi.mock("../../plugins/installed-plugin-index.js", () => ({
loadInstalledPluginIndex: providerDiscoveryMocks.loadInstalledPluginIndex,
resolveInstalledPluginContributionOwners:
providerDiscoveryMocks.resolveInstalledPluginContributionOwners,
vi.mock("../../plugins/plugin-registry.js", () => ({
loadPluginRegistrySnapshot: providerDiscoveryMocks.loadPluginRegistrySnapshot,
resolvePluginContributionOwners: providerDiscoveryMocks.resolvePluginContributionOwners,
resolveProviderOwners: providerDiscoveryMocks.resolveProviderOwners,
}));
vi.mock("../../plugins/providers.js", () => ({
@@ -113,20 +114,17 @@ const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider];
describe("loadProviderCatalogModelsForList", () => {
beforeEach(() => {
vi.clearAllMocks();
providerDiscoveryMocks.loadInstalledPluginIndex.mockReturnValue({
providerDiscoveryMocks.loadPluginRegistrySnapshot.mockReturnValue({
plugins: [],
diagnostics: [],
});
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.resolveProviderOwners.mockImplementation(
({ providerId }: { providerId: string }) =>
defaultProviders
.filter((provider) => provider.id === providerId)
.map((provider) => provider.pluginId),
);
providerDiscoveryMocks.resolvePluginContributionOwners.mockReturnValue([]);
providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValue([
"chutes",
"moonshot",
@@ -198,7 +196,7 @@ describe("loadProviderCatalogModelsForList", () => {
}),
).resolves.toEqual(["moonshot"]);
expect(providerDiscoveryMocks.loadInstalledPluginIndex).toHaveBeenCalledWith({
expect(providerDiscoveryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({
config: baseParams.cfg,
env: baseParams.env,
});
@@ -206,11 +204,10 @@ describe("loadProviderCatalogModelsForList", () => {
});
it("does not fall back to legacy manifest ownership for disabled installed-index owners", async () => {
providerDiscoveryMocks.resolveInstalledPluginContributionOwners
providerDiscoveryMocks.resolveProviderOwners
.mockReturnValueOnce([])
.mockReturnValueOnce([])
.mockReturnValueOnce(["moonshot"])
.mockReturnValueOnce([]);
.mockReturnValueOnce(["moonshot"]);
providerDiscoveryMocks.resolvePluginContributionOwners.mockReturnValue([]);
await expect(
resolveProviderCatalogPluginIdsForFilter({
@@ -280,7 +277,7 @@ describe("loadProviderCatalogModelsForList", () => {
});
it("does not skip registry for non-bundled static catalog owners", async () => {
providerDiscoveryMocks.resolveInstalledPluginContributionOwners.mockReturnValueOnce([]);
providerDiscoveryMocks.resolveProviderOwners.mockReturnValueOnce([]);
providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce([
"workspace-static-provider",
]);
@@ -298,7 +295,7 @@ describe("loadProviderCatalogModelsForList", () => {
});
it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => {
providerDiscoveryMocks.resolveInstalledPluginContributionOwners.mockReturnValueOnce([]);
providerDiscoveryMocks.resolveProviderOwners.mockReturnValueOnce([]);
await expect(
resolveProviderCatalogPluginIdsForFilter({
@@ -350,7 +347,7 @@ describe("loadProviderCatalogModelsForList", () => {
});
it("keeps unknown provider filters eligible for early empty results", async () => {
providerDiscoveryMocks.resolveInstalledPluginContributionOwners.mockReturnValueOnce([]);
providerDiscoveryMocks.resolveProviderOwners.mockReturnValueOnce([]);
await expect(
resolveProviderCatalogPluginIdsForFilter({

View File

@@ -5,10 +5,11 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
type InstalledPluginIndex,
loadInstalledPluginIndex,
resolveInstalledPluginContributionOwners,
} from "../../plugins/installed-plugin-index.js";
loadPluginRegistrySnapshot,
resolvePluginContributionOwners,
resolveProviderOwners,
type PluginRegistrySnapshot,
} from "../../plugins/plugin-registry.js";
import {
groupPluginDiscoveryProvidersByOrder,
normalizePluginDiscoveryResult,
@@ -37,18 +38,27 @@ function providerMatchesFilter(params: {
}
function collectMatchingContributionOwners(
index: InstalledPluginIndex,
index: PluginRegistrySnapshot,
contribution: "providers" | "cliBackends",
providerFilter: string,
options: { includeDisabled?: boolean } = {},
): string[] {
if (contribution === "providers") {
return [
...resolveProviderOwners({
index,
providerId: providerFilter,
includeDisabled: options.includeDisabled,
}),
];
}
return [
...resolveInstalledPluginContributionOwners(
...resolvePluginContributionOwners({
index,
contribution,
(contributionId) => normalizeProviderId(contributionId) === providerFilter,
options,
),
contribution: "cliBackends",
matches: (contributionId) => normalizeProviderId(contributionId) === providerFilter,
includeDisabled: options.includeDisabled,
}),
];
}
@@ -57,7 +67,7 @@ function resolveInstalledIndexPluginIdsForProviderFilter(params: {
env?: NodeJS.ProcessEnv;
providerFilter: string;
}): string[] | undefined {
const index = loadInstalledPluginIndex({
const index = loadPluginRegistrySnapshot({
config: params.cfg,
env: params.env,
});

View File

@@ -0,0 +1,179 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import {
getPluginRecord,
inspectPluginRegistry,
isPluginEnabled,
listPluginContributionIds,
listPluginRecords,
loadPluginRegistrySnapshot,
refreshPluginRegistry,
resolveChannelOwners,
resolveCliBackendOwners,
resolvePluginContributionOwners,
resolveProviderOwners,
resolveSetupProviderOwners,
} from "./plugin-registry.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
function makeTempDir() {
return makeTrackedTempDir("openclaw-plugin-registry", tempDirs);
}
function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
...overrides,
};
}
function createCandidate(rootDir: string): PluginCandidate {
fs.writeFileSync(
path.join(rootDir, "index.ts"),
"throw new Error('runtime entry should not load while reading plugin registry');\n",
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "demo",
name: "Demo",
configSchema: { type: "object" },
providers: ["demo"],
channels: ["demo-chat"],
cliBackends: ["demo-cli"],
setup: {
providers: [{ id: "demo-setup", envVars: ["DEMO_API_KEY"] }],
cliBackends: ["demo-setup-cli"],
},
channelConfigs: {
"demo-chat": {
schema: { type: "object" },
},
},
modelCatalog: {
providers: {
demo: {
models: [{ id: "demo-model" }],
},
},
},
commandAliases: [{ name: "demo-command" }],
contracts: {
tools: ["demo-tool"],
},
}),
"utf8",
);
return {
idHint: "demo",
source: path.join(rootDir, "index.ts"),
rootDir,
origin: "global",
};
}
describe("plugin registry facade", () => {
it("resolves cold plugin records and contribution owners without loading runtime", () => {
const rootDir = makeTempDir();
const candidate = createCandidate(rootDir);
const index = loadPluginRegistrySnapshot({
candidates: [candidate],
env: hermeticEnv(),
});
expect(listPluginRecords({ index }).map((plugin) => plugin.pluginId)).toEqual(["demo"]);
expect(getPluginRecord({ index, pluginId: "demo" })).toMatchObject({
pluginId: "demo",
enabled: true,
});
expect(isPluginEnabled({ index, pluginId: "demo" })).toBe(true);
expect(listPluginContributionIds({ index, contribution: "providers" })).toEqual(["demo"]);
expect(resolveProviderOwners({ index, providerId: "demo" })).toEqual(["demo"]);
expect(resolveChannelOwners({ index, channelId: "demo-chat" })).toEqual(["demo"]);
expect(resolveCliBackendOwners({ index, cliBackendId: "demo-cli" })).toEqual(["demo"]);
expect(
resolvePluginContributionOwners({
index,
contribution: "cliBackends",
matches: (contributionId) => contributionId === "demo-cli",
}),
).toEqual(["demo"]);
expect(resolveSetupProviderOwners({ index, setupProviderId: "demo-setup" })).toEqual(["demo"]);
});
it("keeps disabled records inspectable while excluding owners by default", () => {
const rootDir = makeTempDir();
const candidate = createCandidate(rootDir);
const index = loadPluginRegistrySnapshot({
candidates: [candidate],
config: {
plugins: {
entries: {
demo: {
enabled: false,
},
},
},
},
env: hermeticEnv(),
});
expect(getPluginRecord({ index, pluginId: "demo" })).toMatchObject({
pluginId: "demo",
enabled: false,
});
expect(resolveProviderOwners({ index, providerId: "demo" })).toEqual([]);
expect(resolveProviderOwners({ index, providerId: "demo", includeDisabled: true })).toEqual([
"demo",
]);
});
it("exposes explicit persisted registry inspect and refresh operations", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "demo");
fs.mkdirSync(pluginDir, { recursive: true });
const candidate = createCandidate(pluginDir);
const env = hermeticEnv();
await expect(
inspectPluginRegistry({ stateDir, candidates: [candidate], env }),
).resolves.toMatchObject({
state: "missing",
refreshReasons: ["missing"],
persisted: null,
current: {
plugins: [expect.objectContaining({ pluginId: "demo" })],
},
});
await refreshPluginRegistry({
reason: "manual",
stateDir,
candidates: [candidate],
env,
});
await expect(
inspectPluginRegistry({ stateDir, candidates: [candidate], env }),
).resolves.toMatchObject({
state: "fresh",
refreshReasons: [],
persisted: {
plugins: [expect.objectContaining({ pluginId: "demo" })],
},
});
});
});

View File

@@ -0,0 +1,174 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import type {
InstalledPluginIndexStoreInspection,
InstalledPluginIndexStoreOptions,
} from "./installed-plugin-index-store.js";
import {
getInstalledPluginRecord,
isInstalledPluginEnabled,
listInstalledPluginContributionIds,
listInstalledPluginRecords,
loadInstalledPluginIndex,
resolveInstalledPluginContributionOwners,
type InstalledPluginContributionKey,
type InstalledPluginIndex,
type InstalledPluginIndexRecord,
type LoadInstalledPluginIndexParams,
type RefreshInstalledPluginIndexParams,
} from "./installed-plugin-index.js";
export type PluginRegistrySnapshot = InstalledPluginIndex;
export type PluginRegistryRecord = InstalledPluginIndexRecord;
export type PluginRegistryInspection = InstalledPluginIndexStoreInspection;
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & {
index?: PluginRegistrySnapshot;
};
export type PluginRegistryContributionOptions = LoadPluginRegistryParams & {
includeDisabled?: boolean;
};
export type GetPluginRecordParams = LoadPluginRegistryParams & {
pluginId: string;
};
export type ResolvePluginContributionOwnersParams = PluginRegistryContributionOptions & {
contribution: InstalledPluginContributionKey;
matches: string | ((contributionId: string) => boolean);
};
export type ListPluginContributionIdsParams = PluginRegistryContributionOptions & {
contribution: InstalledPluginContributionKey;
};
export type ResolveProviderOwnersParams = PluginRegistryContributionOptions & {
providerId: string;
};
export type ResolveChannelOwnersParams = PluginRegistryContributionOptions & {
channelId: string;
};
export type ResolveCliBackendOwnersParams = PluginRegistryContributionOptions & {
cliBackendId: string;
};
export type ResolveSetupProviderOwnersParams = PluginRegistryContributionOptions & {
setupProviderId: string;
};
function normalizeContributionId(value: string): string {
return value.trim();
}
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {
return params.index ?? loadInstalledPluginIndex(params);
}
export function loadPluginRegistrySnapshot(
params: LoadPluginRegistryParams = {},
): PluginRegistrySnapshot {
return resolveSnapshot(params);
}
export function listPluginRecords(
params: LoadPluginRegistryParams = {},
): readonly PluginRegistryRecord[] {
return listInstalledPluginRecords(resolveSnapshot(params));
}
export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined {
return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId);
}
export function isPluginEnabled(params: GetPluginRecordParams): boolean {
return isInstalledPluginEnabled(resolveSnapshot(params), params.pluginId);
}
export function listPluginContributionIds(
params: ListPluginContributionIdsParams,
): readonly string[] {
return listInstalledPluginContributionIds(resolveSnapshot(params), params.contribution, {
includeDisabled: params.includeDisabled,
});
}
export function resolvePluginContributionOwners(
params: ResolvePluginContributionOwnersParams,
): readonly string[] {
return resolveInstalledPluginContributionOwners(
resolveSnapshot(params),
params.contribution,
params.matches,
{
includeDisabled: params.includeDisabled,
},
);
}
export function resolveProviderOwners(params: ResolveProviderOwnersParams): readonly string[] {
const providerId = normalizeProviderId(params.providerId);
if (!providerId) {
return [];
}
return resolvePluginContributionOwners({
...params,
contribution: "providers",
matches: (contributionId) => normalizeProviderId(contributionId) === providerId,
});
}
export function resolveChannelOwners(params: ResolveChannelOwnersParams): readonly string[] {
const channelId = normalizeContributionId(params.channelId);
if (!channelId) {
return [];
}
return resolvePluginContributionOwners({
...params,
contribution: "channels",
matches: channelId,
});
}
export function resolveCliBackendOwners(params: ResolveCliBackendOwnersParams): readonly string[] {
const cliBackendId = normalizeContributionId(params.cliBackendId);
if (!cliBackendId) {
return [];
}
return resolvePluginContributionOwners({
...params,
contribution: "cliBackends",
matches: cliBackendId,
});
}
export function resolveSetupProviderOwners(
params: ResolveSetupProviderOwnersParams,
): readonly string[] {
const setupProviderId = normalizeContributionId(params.setupProviderId);
if (!setupProviderId) {
return [];
}
return resolvePluginContributionOwners({
...params,
contribution: "setupProviders",
matches: setupProviderId,
});
}
export function inspectPluginRegistry(
params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {},
): Promise<PluginRegistryInspection> {
return import("./installed-plugin-index-store.js").then((store) =>
store.inspectPersistedInstalledPluginIndex(params),
);
}
export function refreshPluginRegistry(
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
): Promise<PluginRegistrySnapshot> {
return import("./installed-plugin-index-store.js").then((store) =>
store.refreshPersistedInstalledPluginIndex(params),
);
}

View File

@@ -2,11 +2,11 @@ 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,
} from "./installed-plugin-index.js";
listPluginContributionIds,
loadPluginRegistrySnapshot,
type LoadPluginRegistryParams,
type PluginRegistrySnapshot,
} from "./plugin-registry.js";
import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js";
const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"];
@@ -44,8 +44,8 @@ export type ResolveRuntimePluginDiscoveryProvidersParams = {
discoveryEntriesOnly?: boolean;
};
export type ResolveInstalledPluginProviderContributionIdsParams = LoadInstalledPluginIndexParams & {
index?: InstalledPluginIndex;
export type ResolveInstalledPluginProviderContributionIdsParams = LoadPluginRegistryParams & {
index?: PluginRegistrySnapshot;
includeDisabled?: boolean;
};
@@ -56,9 +56,11 @@ function sortedValues(values: Iterable<string>): string[] {
export function resolveInstalledPluginProviderContributionIds(
params: ResolveInstalledPluginProviderContributionIdsParams = {},
): string[] {
const index = params.index ?? loadInstalledPluginIndex(params);
const index = params.index ?? loadPluginRegistrySnapshot(params);
return sortedValues(
listInstalledPluginContributionIds(index, "providers", {
listPluginContributionIds({
index,
contribution: "providers",
includeDisabled: params.includeDisabled,
}),
);