refactor: scope plugin capabilities to manifests

This commit is contained in:
Shakker
2026-04-26 02:13:24 +01:00
parent a932a58e87
commit 4e3b860e60
19 changed files with 549 additions and 387 deletions

View File

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

View File

@@ -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] : [];
});
}

View File

@@ -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",

View File

@@ -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<string, string>();
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<string, unknown>;
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<string>();
const installedPluginIds = new Set<string>();
@@ -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);

View File

@@ -50,6 +50,17 @@ import {
resolveGatewayStartupPluginIds,
} from "./channel-plugin-ids.js";
function withManifestLoadPaths<T extends { id: string }>(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),
};
}

View File

@@ -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<string>;
}): 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<typeof normalizePluginsConfigWithRegistry>;
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 (

View File

@@ -39,16 +39,6 @@ function createIndex(overrides: Partial<InstalledPluginIndex> = {}): 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"] }),
}),
],
},

View File

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

View File

@@ -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",

View File

@@ -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<string, readonly string[]>;
channels: ReadonlyMap<string, readonly string[]>;
channelConfigs: ReadonlyMap<string, readonly string[]>;
setupProviders: ReadonlyMap<string, readonly string[]>;
cliBackends: ReadonlyMap<string, readonly string[]>;
modelCatalogProviders: ReadonlyMap<string, readonly string[]>;
commandAliases: ReadonlyMap<string, readonly string[]>;
contracts: ReadonlyMap<string, readonly string[]>;
};
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<string, unknown> | 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<string, string[]>,
contributionId: string,
pluginId: string,
): void {
const existing = target.get(contributionId);
if (existing) {
existing.push(pluginId);
} else {
target.set(contributionId, [pluginId]);
}
}
function freezeContributionMap(
source: Map<string, string[]>,
): ReadonlyMap<string, readonly string[]> {
const frozen = new Map<string, readonly string[]>();
for (const [key, pluginIds] of source) {
frozen.set(key, sortUnique(pluginIds));
}
return frozen;
}
export function resolveInstalledPluginContributions(
index: InstalledPluginIndex,
): InstalledPluginContributions {
const providers = new Map<string, string[]>();
const channels = new Map<string, string[]>();
const channelConfigs = new Map<string, string[]>();
const setupProviders = new Map<string, string[]>();
const cliBackends = new Map<string, string[]>();
const modelCatalogProviders = new Map<string, string[]>();
const commandAliases = new Map<string, string[]>();
const contracts = new Map<string, string[]>();
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,

View File

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

View File

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

View File

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

View File

@@ -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>): string[] {
return [...new Set([...values].map((value) => value.trim()).filter(Boolean))].toSorted(
(left, right) => left.localeCompare(right),
);
}
function collectObjectKeys(value: Record<string, unknown> | 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<string, string>();
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] : [],
),
);
}

View File

@@ -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<string> {
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<typeof plugin.contracts>] ?? [])
.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,

View File

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

View File

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

View File

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

View File

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