refactor: route plugin metadata consumers through snapshots

This commit is contained in:
Peter Steinberger
2026-05-02 08:41:14 +01:00
parent cf35fa8e57
commit 2f44ffc8a7
18 changed files with 257 additions and 162 deletions

View File

@@ -11,9 +11,21 @@ import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
const hoisted = vi.hoisted(() => {
const loadManifestRegistry = vi.fn();
const loadPluginMetadataSnapshot = vi.fn(() => {
const manifestRegistry = loadManifestRegistry();
return {
manifestRegistry,
plugins: manifestRegistry.plugins,
normalizePluginId: (pluginId: string) =>
manifestRegistry.plugins.find((plugin: { id: string; legacyPluginIds?: string[] }) =>
plugin.legacyPluginIds?.includes(pluginId),
)?.id ?? pluginId,
};
});
return {
loadPluginManifestRegistryForInstalledIndex: loadManifestRegistry,
loadPluginManifestRegistryForPluginRegistry: loadManifestRegistry,
loadPluginMetadataSnapshot,
loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })),
};
});
@@ -27,6 +39,10 @@ vi.mock("../../plugins/plugin-registry.js", () => ({
loadPluginRegistrySnapshot: hoisted.loadPluginRegistrySnapshot,
}));
vi.mock("../../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: hoisted.loadPluginMetadataSnapshot,
}));
let resolvePluginSkillDirs: typeof import("./plugin-skills.js").resolvePluginSkillDirs;
const tempDirs = createTrackedTempDirs();
@@ -135,6 +151,7 @@ function registerHealthyAcpBackend() {
afterEach(async () => {
hoisted.loadPluginManifestRegistryForInstalledIndex.mockReset();
hoisted.loadPluginMetadataSnapshot.mockClear();
hoisted.loadPluginRegistrySnapshot.mockReset();
acpRuntimeTesting.resetAcpRuntimeBackendsForTests();
await tempDirs.cleanup();
@@ -151,6 +168,7 @@ describe("resolvePluginSkillDirs", () => {
diagnostics: [],
plugins: [],
});
hoisted.loadPluginMetadataSnapshot.mockClear();
hoisted.loadPluginRegistrySnapshot.mockReset();
hoisted.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] });
});

View File

@@ -8,40 +8,12 @@ import {
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "../../plugins/config-policy.js";
import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import { hasKind } from "../../plugins/slots.js";
import { isPathInsideWithRealpath } from "../../security/scan-paths.js";
const log = createSubsystemLogger("skills");
function buildRegistryPluginIdAliases(
registry: PluginManifestRegistry,
): Readonly<Record<string, string>> {
return Object.fromEntries(
registry.plugins
.flatMap((record) => [
...record.providers
.filter((providerId) => providerId !== record.id)
.map((providerId) => [providerId, record.id] as const),
...(record.legacyPluginIds ?? []).map(
(legacyPluginId) => [legacyPluginId, record.id] as const,
),
])
.toSorted(([left], [right]) => left.localeCompare(right)),
);
}
function createRegistryPluginIdNormalizer(
registry: PluginManifestRegistry,
): (id: string) => string {
const aliases = buildRegistryPluginIdAliases(registry);
return (id: string) => {
const trimmed = id.trim();
return aliases[trimmed] ?? trimmed;
};
}
export function resolvePluginSkillDirs(params: {
workspaceDir: string | undefined;
config?: OpenClawConfig;
@@ -50,17 +22,18 @@ export function resolvePluginSkillDirs(params: {
if (!workspaceDir) {
return [];
}
const registry = loadPluginManifestRegistryForPluginRegistry({
const metadataSnapshot = loadPluginMetadataSnapshot({
workspaceDir,
config: params.config,
includeDisabled: true,
config: params.config ?? {},
env: process.env,
});
const registry = metadataSnapshot.manifestRegistry;
if (registry.plugins.length === 0) {
return [];
}
const normalizedPlugins = normalizePluginsConfigWithResolver(
params.config?.plugins,
createRegistryPluginIdNormalizer(registry),
metadataSnapshot.normalizePluginId,
);
const acpRuntimeAvailable = isAcpRuntimeSpawnAvailable({ config: params.config });
const memorySlot = normalizedPlugins.slots.memory;

View File

@@ -310,11 +310,11 @@ function createBundledChannelLoadContext(): BundledChannelLoadContext {
};
}
function resolveActiveBundledChannelLoadScope(): {
function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.env): {
rootScope: BundledChannelRootScope;
loadContext: BundledChannelLoadContext;
} {
const rootScope = resolveBundledChannelRootScope();
const rootScope = resolveBundledChannelRootScope(env);
const cachedContext = bundledChannelLoadContextsByRoot.get(rootScope.cacheKey);
if (cachedContext) {
bundledChannelLoadContextsByRoot.delete(rootScope.cacheKey);
@@ -787,13 +787,19 @@ export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"
return getBundledChannelSecretsForRoot(id, rootScope, loadContext);
}
export function getBundledChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope();
export function getBundledChannelSetupPlugin(
id: ChannelId,
env: NodeJS.ProcessEnv = process.env,
): ChannelPlugin | undefined {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env);
return getBundledChannelSetupPluginForRoot(id, rootScope, loadContext);
}
export function getBundledChannelSetupSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope();
export function getBundledChannelSetupSecrets(
id: ChannelId,
env: NodeJS.ProcessEnv = process.env,
): ChannelPlugin["secrets"] | undefined {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env);
return getBundledChannelSetupSecretsForRoot(id, rootScope, loadContext);
}

View File

@@ -2,18 +2,24 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { isBlockedObjectKey } from "../../infra/prototype-keys.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
hasExplicitChannelConfig,
listConfiguredChannelIdsForReadOnlyScope,
resolveDiscoverableScopedChannelPluginIds,
} from "../../plugins/channel-plugin-ids.js";
import {
channelPluginIdBelongsToManifest,
resolveSetupChannelRegistration,
} from "../../plugins/loader-channel-setup.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "../../plugins/plugin-module-loader-cache.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { getBundledChannelSetupPlugin } from "./bundled.js";
@@ -35,6 +41,7 @@ const BUILT_PLUGIN_LOADER_MODULE_CANDIDATES = [
"plugins/build-smoke-entry.js",
] as const;
const moduleLoaders: PluginModuleLoaderCache = new Map();
const log = createSubsystemLogger("channels");
type PluginLoaderModule = {
loadOpenClawPlugins: (params: {
@@ -366,6 +373,44 @@ function canUseManifestChannelPlugin(record: PluginManifestRecord, channelId: st
export { resolveReadOnlyChannelCommandDefaults };
function loadSetupChannelPluginFromManifestRecord(params: {
record: PluginManifestRecord;
channelId: string;
}): ChannelPlugin | undefined {
if (!params.record.setupSource || !params.record.channels.includes(params.channelId)) {
return undefined;
}
try {
const moduleLoader = getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath: params.record.setupSource,
importerUrl: import.meta.url,
preferBuiltDist: true,
loaderFilename: import.meta.url,
tryNative: true,
cacheScopeKey: "read-only-setup-entry",
});
const registration = resolveSetupChannelRegistration(moduleLoader(params.record.setupSource));
if (!registration.plugin) {
return undefined;
}
if (
!channelPluginIdBelongsToManifest({
channelId: registration.plugin.id,
pluginId: params.record.id,
manifestChannels: params.record.channels,
})
) {
return undefined;
}
return cloneChannelPluginForChannelId(registration.plugin, params.channelId);
} catch (error) {
const detail = formatErrorMessage(error);
log.warn(`[channels] failed to load channel setup ${params.record.id}: ${detail}`);
return undefined;
}
}
function rebindChannelPluginConfig(
config: ChannelPlugin["config"],
sourceChannelId: string,
@@ -652,12 +697,11 @@ export function resolveReadOnlyChannelPluginsForConfig(
): ReadOnlyChannelPluginResolution {
const env = options.env ?? process.env;
const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options);
const manifestRecords = loadPluginManifestRegistryForPluginRegistry({
const manifestRecords = loadPluginMetadataSnapshot({
config: cfg,
stateDir: options.stateDir,
workspaceDir,
env,
includeDisabled: true,
}).plugins;
const bundledManifestRecords = listBundledChannelManifestRecords(manifestRecords);
const externalManifestRecords = listExternalChannelManifestRecords(manifestRecords);
@@ -682,7 +726,17 @@ export function resolveReadOnlyChannelPluginsForConfig(
if (byId.has(channelId)) {
continue;
}
addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]);
const bundledSetupPlugin =
bundledManifestRecords
.filter((record) => record.channels.includes(channelId))
.map((record) =>
loadSetupChannelPluginFromManifestRecord({
record,
channelId,
}),
)
.find((plugin) => plugin) ?? getBundledChannelSetupPlugin(channelId, env);
addChannelPlugins(byId, [bundledSetupPlugin]);
}
}

View File

@@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub.js";
import type { PluginKind } from "../plugins/plugin-kind.types.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
import type { PluginLogger } from "../plugins/types.js";
@@ -59,13 +59,12 @@ function buildSlotSelectionRegistry(
config: OpenClawConfig,
pluginId: string,
): SlotSelectionRegistry {
const registry = loadPluginManifestRegistryForPluginRegistry({
const plugins = loadPluginMetadataSnapshot({
config,
includeDisabled: true,
pluginIds: [pluginId],
});
env: process.env,
}).plugins.filter((plugin) => plugin.id === pluginId);
return {
plugins: registry.plugins.map((plugin) => ({
plugins: plugins.map((plugin) => ({
id: plugin.id,
kind: plugin.kind,
})),

View File

@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
vi.mock("../../../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: () => ({
vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: () => ({
plugins: [
{
id: "brave",
@@ -21,8 +21,6 @@ vi.mock("../../../plugins/plugin-registry.js", () => ({
},
],
}),
resolveManifestContractOwnerPluginId: ({ value }: { value: string }) =>
({ brave: "brave", grok: "xai", kimi: "moonshot" })[value as "brave" | "grok" | "kimi"],
}));
import {

View File

@@ -1,8 +1,5 @@
import { mergeMissing } from "../../../config/legacy.shared.js";
import {
loadPluginManifestRegistryForPluginRegistry,
resolveManifestContractOwnerPluginId,
} from "../../../plugins/plugin-registry.js";
import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js";
import {
cloneRecord,
ensureRecord,
@@ -18,18 +15,31 @@ const MODERN_SCOPED_WEB_SEARCH_KEYS = new Set(["openaiCodex"]);
const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set(["tavily"]);
const LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID = "brave";
function getLegacyWebSearchProviderIds(): string[] {
return loadPluginManifestRegistryForPluginRegistry({
includeDisabled: true,
})
.plugins.filter((plugin) => plugin.origin === "bundled")
.flatMap((plugin) => plugin.contracts?.webSearchProviders ?? [])
function getBundledLegacyWebSearchOwners(): ReadonlyMap<string, string> {
const owners = new Map<string, string>();
for (const plugin of loadPluginMetadataSnapshot({ config: {}, env: process.env }).plugins) {
if (plugin.origin !== "bundled") {
continue;
}
for (const providerId of plugin.contracts?.webSearchProviders ?? []) {
if (!owners.has(providerId)) {
owners.set(providerId, plugin.id);
}
}
}
return owners;
}
function getLegacyWebSearchProviderIds(
owners: ReadonlyMap<string, string> = getBundledLegacyWebSearchOwners(),
): string[] {
return [...owners.keys()]
.filter((providerId) => !NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS.has(providerId))
.toSorted((left, right) => left.localeCompare(right));
}
function getLegacyWebSearchProviderIdSet(): Set<string> {
return new Set(getLegacyWebSearchProviderIds());
function getLegacyWebSearchProviderIdSet(owners: ReadonlyMap<string, string>): Set<string> {
return new Set(getLegacyWebSearchProviderIds(owners));
}
function resolveLegacySearchConfig(raw: unknown): JsonRecord | undefined {
@@ -46,7 +56,10 @@ function copyLegacyProviderConfig(search: JsonRecord, providerKey: string): Json
return isRecord(current) ? cloneRecord(current) : undefined;
}
function hasMappedLegacyWebSearchConfig(raw: unknown): boolean {
function hasMappedLegacyWebSearchConfig(
raw: unknown,
owners: ReadonlyMap<string, string>,
): boolean {
const search = resolveLegacySearchConfig(raw);
if (!search) {
return false;
@@ -54,10 +67,13 @@ function hasMappedLegacyWebSearchConfig(raw: unknown): boolean {
if (hasOwnKey(search, "apiKey")) {
return true;
}
return getLegacyWebSearchProviderIds().some((providerId) => isRecord(search[providerId]));
return getLegacyWebSearchProviderIds(owners).some((providerId) => isRecord(search[providerId]));
}
function resolveLegacyGlobalWebSearchMigration(search: JsonRecord): {
function resolveLegacyGlobalWebSearchMigration(
search: JsonRecord,
owners: ReadonlyMap<string, string>,
): {
pluginId: string;
payload: JsonRecord;
legacyPath: string;
@@ -76,11 +92,7 @@ function resolveLegacyGlobalWebSearchMigration(search: JsonRecord): {
return null;
}
const pluginId =
resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID,
origin: "bundled",
}) ?? LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID;
owners.get(LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID) ?? LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID;
return {
pluginId,
payload,
@@ -134,6 +146,7 @@ function migratePluginWebSearchConfig(params: {
}
export function listLegacyWebSearchConfigPaths(raw: unknown): string[] {
const owners = getBundledLegacyWebSearchOwners();
const search = resolveLegacySearchConfig(raw);
if (!search) {
return [];
@@ -143,7 +156,7 @@ export function listLegacyWebSearchConfigPaths(raw: unknown): string[] {
if ("apiKey" in search) {
paths.push("tools.web.search.apiKey");
}
for (const providerId of getLegacyWebSearchProviderIds()) {
for (const providerId of getLegacyWebSearchProviderIds(owners)) {
const scoped = search[providerId];
if (isRecord(scoped)) {
for (const key of Object.keys(scoped)) {
@@ -159,15 +172,17 @@ export function migrateLegacyWebSearchConfig<T>(raw: T): { config: T; changes: s
return { config: raw, changes: [] };
}
if (!hasMappedLegacyWebSearchConfig(raw)) {
const owners = getBundledLegacyWebSearchOwners();
if (!hasMappedLegacyWebSearchConfig(raw, owners)) {
return { config: raw, changes: [] };
}
return normalizeLegacyWebSearchConfigRecord(raw);
return normalizeLegacyWebSearchConfigRecord(raw, owners);
}
function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
raw: T,
owners: ReadonlyMap<string, string>,
): {
config: T;
changes: string[];
@@ -186,7 +201,7 @@ function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
if (key === "apiKey") {
continue;
}
if (getLegacyWebSearchProviderIdSet().has(key) && isRecord(value)) {
if (getLegacyWebSearchProviderIdSet(owners).has(key) && isRecord(value)) {
continue;
}
if (MODERN_SCOPED_WEB_SEARCH_KEYS.has(key) || !isRecord(value)) {
@@ -195,7 +210,7 @@ function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
}
web.search = nextSearch;
const globalSearchMigration = resolveLegacyGlobalWebSearchMigration(search);
const globalSearchMigration = resolveLegacyGlobalWebSearchMigration(search, owners);
if (globalSearchMigration) {
migratePluginWebSearchConfig({
root: nextRoot,
@@ -207,7 +222,7 @@ function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
});
}
for (const providerId of getLegacyWebSearchProviderIds()) {
for (const providerId of getLegacyWebSearchProviderIds(owners)) {
if (providerId === LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID) {
continue;
}
@@ -215,11 +230,7 @@ function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
if (!scoped || Object.keys(scoped).length === 0) {
continue;
}
const pluginId = resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: providerId,
origin: "bundled",
});
const pluginId = owners.get(providerId);
if (!pluginId) {
continue;
}

View File

@@ -4,7 +4,7 @@ const mocks = vi.hoisted(() => ({
installPluginFromNpmSpec: vi.fn(),
listChannelPluginCatalogEntries: vi.fn(),
loadInstalledPluginIndexInstallRecords: vi.fn(),
loadPluginManifestRegistryForPluginRegistry: vi.fn(),
loadPluginMetadataSnapshot: vi.fn(),
resolveDefaultPluginExtensionsDir: vi.fn(() => "/tmp/openclaw-plugins"),
resolveProviderInstallCatalogEntries: vi.fn(),
updateNpmInstalledPlugins: vi.fn(),
@@ -29,8 +29,8 @@ vi.mock("../../../plugins/install.js", () => ({
installPluginFromNpmSpec: mocks.installPluginFromNpmSpec,
}));
vi.mock("../../../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: mocks.loadPluginManifestRegistryForPluginRegistry,
vi.mock("../../../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: mocks.loadPluginMetadataSnapshot,
}));
vi.mock("../../../plugins/provider-install-catalog.js", () => ({
@@ -44,7 +44,7 @@ vi.mock("../../../plugins/update.js", () => ({
describe("repairMissingConfiguredPluginInstalls", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [],
diagnostics: [],
});

View File

@@ -6,7 +6,7 @@ import { installPluginFromNpmSpec } from "../../../plugins/install.js";
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js";
import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js";
import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js";
import { updateNpmInstalledPlugins } from "../../../plugins/update.js";
import { asObjectRecord } from "./object.js";
@@ -153,12 +153,12 @@ export async function repairMissingConfiguredPluginInstalls(params: {
env?: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: string[] }> {
const env = params.env ?? process.env;
const registry = loadPluginManifestRegistryForPluginRegistry({
config: params.cfg,
env,
includeDisabled: true,
});
const knownIds = new Set(registry.plugins.map((plugin) => plugin.id));
const knownIds = new Set(
loadPluginMetadataSnapshot({
config: params.cfg,
env,
}).plugins.map((plugin) => plugin.id),
);
const records = await loadInstalledPluginIndexInstallRecords({ env });
const configuredPluginIds = collectConfiguredPluginIds(params.cfg);
const missingRecordedPluginIds = Object.keys(records).filter(

View File

@@ -2,7 +2,7 @@ import { normalizeToolName } from "../../../agents/tool-policy-shared.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { normalizePluginId } from "../../../plugins/config-state.js";
import type { PluginManifestRegistry } from "../../../plugins/manifest-registry.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js";
import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js";
type ToolAllowlistSource = {
label: string;
@@ -147,11 +147,10 @@ export function collectPluginToolAllowlistWarnings(params: {
const registry =
params.manifestRegistry ??
loadPluginManifestRegistryForPluginRegistry({
loadPluginMetadataSnapshot({
config: params.cfg,
env: params.env,
includeDisabled: true,
});
env: params.env ?? process.env,
}).manifestRegistry;
const knownPluginIds = collectKnownPluginIds(registry);
const toolOwners = collectToolOwners(registry);
const missingPluginIssues = new Map<string, Set<string>>();

View File

@@ -3,7 +3,7 @@ import { CHANNEL_IDS } from "../../../channels/ids.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { normalizePluginId } from "../../../plugins/config-state.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "../../../plugins/installed-plugin-index-records.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../../plugins/plugin-registry.js";
import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { asObjectRecord } from "./object.js";
@@ -29,12 +29,11 @@ function collectPluginRegistryState(
env?: NodeJS.ProcessEnv,
): StalePluginRegistryState {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const registry = loadPluginManifestRegistryForPluginRegistry({
const registry = loadPluginMetadataSnapshot({
config: cfg,
workspaceDir: workspaceDir ?? undefined,
env,
includeDisabled: true,
});
env: env ?? process.env,
}).manifestRegistry;
const knownIds = new Set(registry.plugins.map((plugin) => plugin.id));
const installedIds = new Set<string>();
for (const pluginId of Object.keys(cfg.plugins?.installs ?? {})) {

View File

@@ -1,22 +1,20 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadPluginRegistrySnapshot: vi.fn(),
loadPluginMetadataSnapshot: vi.fn(),
resolvePluginContributionOwners: vi.fn(),
getPluginRecord: vi.fn(),
isPluginEnabled: vi.fn(),
loadPluginManifestRegistryForInstalledIndex: vi.fn(),
}));
vi.mock("../../plugins/plugin-registry.js", () => ({
loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot,
resolvePluginContributionOwners: mocks.resolvePluginContributionOwners,
getPluginRecord: mocks.getPluginRecord,
isPluginEnabled: mocks.isPluginEnabled,
}));
vi.mock("../../plugins/manifest-registry-installed.js", () => ({
loadPluginManifestRegistryForInstalledIndex: mocks.loadPluginManifestRegistryForInstalledIndex,
vi.mock("../../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: mocks.loadPluginMetadataSnapshot,
}));
const moonshotPlugin = {
@@ -53,10 +51,14 @@ describe("loadStaticManifestCatalogRowsForList", () => {
it("loads only static manifest catalog rows without a provider filter", async () => {
const { loadStaticManifestCatalogRowsForList } = await import("./list.manifest-catalog.js");
const index = { plugins: [], diagnostics: [] };
mocks.loadPluginRegistrySnapshot.mockReturnValueOnce(index);
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValueOnce({
const manifestRegistry = {
plugins: [openrouterPlugin, moonshotPlugin],
diagnostics: [],
};
mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({
index,
manifestRegistry,
plugins: manifestRegistry.plugins,
});
expect(
@@ -64,20 +66,23 @@ describe("loadStaticManifestCatalogRowsForList", () => {
cfg: {},
}).map((row) => row.ref),
).toEqual(["moonshot/kimi-k2.6"]);
expect(mocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith({
index,
expect(mocks.loadPluginMetadataSnapshot).toHaveBeenCalledWith({
config: {},
env: undefined,
env: process.env,
});
});
it("loads refreshable manifest rows as registry-backed supplements", async () => {
const { loadSupplementalManifestCatalogRowsForList } =
await import("./list.manifest-catalog.js");
mocks.loadPluginRegistrySnapshot.mockReturnValueOnce({ plugins: [], diagnostics: [] });
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValueOnce({
const manifestRegistry = {
plugins: [openrouterPlugin, moonshotPlugin],
diagnostics: [],
};
mocks.loadPluginMetadataSnapshot.mockReturnValueOnce({
index: { plugins: [], diagnostics: [] },
manifestRegistry,
plugins: manifestRegistry.plugins,
});
expect(

View File

@@ -4,11 +4,11 @@ import {
planManifestModelCatalogRows,
} from "../../model-catalog/index.js";
import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js";
import { loadPluginManifestRegistryForInstalledIndex } from "../../plugins/manifest-registry-installed.js";
import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import {
getPluginRecord,
isPluginEnabled,
loadPluginRegistrySnapshot,
resolvePluginContributionOwners,
type PluginRegistrySnapshot,
} from "../../plugins/plugin-registry.js";
@@ -19,6 +19,7 @@ function loadManifestCatalogRowsForPluginIds(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
index: PluginRegistrySnapshot;
registry: PluginManifestRegistry;
mode: ManifestCatalogRowsForListMode;
pluginIds?: readonly string[];
providerFilter?: string;
@@ -26,12 +27,13 @@ function loadManifestCatalogRowsForPluginIds(params: {
if (params.pluginIds && params.pluginIds.length === 0) {
return [];
}
const registry = loadPluginManifestRegistryForInstalledIndex({
index: params.index,
config: params.cfg,
env: params.env,
pluginIds: params.pluginIds,
});
const pluginIdSet = params.pluginIds ? new Set(params.pluginIds) : undefined;
const registry = pluginIdSet
? {
...params.registry,
plugins: params.registry.plugins.filter((plugin) => pluginIdSet.has(plugin.id)),
}
: params.registry;
const plan = planManifestModelCatalogRows({
registry,
...(params.providerFilter ? { providerFilter: params.providerFilter } : {}),
@@ -96,15 +98,17 @@ function loadManifestCatalogRowsForList(params: {
? normalizeModelCatalogProviderId(params.providerFilter)
: undefined;
const mode = params.mode ?? "static-authoritative";
const index = loadPluginRegistrySnapshot({
const snapshot = loadPluginMetadataSnapshot({
config: params.cfg,
env: params.env,
env: params.env ?? process.env,
});
const index = snapshot.index;
if (!providerFilter) {
return loadManifestCatalogRowsForPluginIds({
cfg: params.cfg,
env: params.env,
index,
registry: snapshot.manifestRegistry,
mode,
});
}
@@ -112,6 +116,7 @@ function loadManifestCatalogRowsForList(params: {
cfg: params.cfg,
env: params.env,
index,
registry: snapshot.manifestRegistry,
mode,
pluginIds: resolveConventionModelCatalogPluginIds({
cfg: params.cfg,
@@ -127,6 +132,7 @@ function loadManifestCatalogRowsForList(params: {
cfg: params.cfg,
env: params.env,
index,
registry: snapshot.manifestRegistry,
mode,
pluginIds: resolveDeclaredModelCatalogPluginIds({
cfg: params.cfg,

View File

@@ -3,11 +3,11 @@ import path from "node:path";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
normalizePluginsConfig,
normalizePluginsConfigWithResolver,
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
} from "../plugins/config-policy.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { hasKind } from "../plugins/slots.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
@@ -26,17 +26,20 @@ export function resolvePluginHookDirs(params: {
if (!workspaceDir) {
return [];
}
const registry = loadPluginManifestRegistryForPluginRegistry({
const metadataSnapshot = loadPluginMetadataSnapshot({
workspaceDir,
config: params.config,
// Hook discovery should reflect freshly written bundle manifests immediately.
includeDisabled: true,
config: params.config ?? {},
env: process.env,
});
const registry = metadataSnapshot.manifestRegistry;
if (registry.plugins.length === 0) {
return [];
}
const normalizedPlugins = normalizePluginsConfig(params.config?.plugins);
const normalizedPlugins = normalizePluginsConfigWithResolver(
params.config?.plugins,
metadataSnapshot.normalizePluginId,
);
const memorySlot = normalizedPlugins.slots.memory;
let selectedMemoryPluginId: string | null = null;
const seen = new Set<string>();

View File

@@ -22,6 +22,16 @@ vi.mock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: loadPluginManifestRegistry,
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: () => {
const registry = loadPluginManifestRegistry();
return {
plugins: registry.plugins,
manifestRegistry: registry,
};
},
}));
import { buildTrajectoryArtifacts, buildTrajectoryRunMetadata } from "./metadata.js";
afterEach(() => {

View File

@@ -10,7 +10,7 @@ import {
sanitizeSupportSnapshotValue,
type SupportRedactionContext,
} from "../logging/diagnostic-support-redaction.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { getActivePluginRegistry, listImportedRuntimePluginIds } from "../plugins/runtime.js";
import { VERSION } from "../version.js";
@@ -136,15 +136,14 @@ function buildPluginsFromManifest(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}) {
const registry = loadPluginManifestRegistryForPluginRegistry({
config: params.config,
const snapshot = loadPluginMetadataSnapshot({
config: params.config ?? {},
workspaceDir: params.workspaceDir,
env: params.env,
includeDisabled: true,
env: params.env ?? process.env,
});
return {
source: "manifest-registry",
entries: registry.plugins
entries: snapshot.plugins
.map((plugin) => ({
id: plugin.id,
name: plugin.name,

View File

@@ -18,6 +18,16 @@ vi.mock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: loadPluginManifestRegistry,
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: () => {
const registry = loadPluginManifestRegistry();
return {
plugins: registry.plugins,
manifestRegistry: registry,
};
},
}));
function makeManifestPlugin(
id: string,
uiHints?: Record<string, PluginConfigUiHint>,

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginConfigUiHint } from "../plugins/types.js";
import { getPath, setPathCreateStrict } from "../secrets/path-utils.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
@@ -16,13 +17,13 @@ export type ConfigurablePlugin = {
jsonSchema?: JsonSchemaObject;
};
type PluginRegistryModule = typeof import("../plugins/plugin-registry.js");
type PluginMetadataSnapshotModule = typeof import("../plugins/plugin-metadata-snapshot.js");
let pluginRegistryModulePromise: Promise<PluginRegistryModule> | undefined;
let pluginMetadataSnapshotModulePromise: Promise<PluginMetadataSnapshotModule> | undefined;
function loadPluginRegistryModule(): Promise<PluginRegistryModule> {
pluginRegistryModulePromise ??= import("../plugins/plugin-registry.js");
return pluginRegistryModulePromise;
function loadPluginMetadataSnapshotModule(): Promise<PluginMetadataSnapshotModule> {
pluginMetadataSnapshotModulePromise ??= import("../plugins/plugin-metadata-snapshot.js");
return pluginMetadataSnapshotModulePromise;
}
type JsonSchemaProperty = {
@@ -141,6 +142,22 @@ export function discoverUnconfiguredPlugins(params: {
});
}
async function listEnabledConfigurableManifestPlugins(params: {
config: OpenClawConfig;
workspaceDir?: string;
}): Promise<readonly PluginManifestRecord[]> {
const { loadPluginMetadataSnapshot } = await loadPluginMetadataSnapshotModule();
const snapshot = loadPluginMetadataSnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: process.env,
});
return snapshot.plugins.filter((plugin) => {
const entry = params.config.plugins?.entries?.[plugin.id];
return plugin.enabledByDefault || entry?.enabled === true;
});
}
/**
* Prompt the user to configure a single plugin's fields via uiHints.
* Returns the updated config with plugin values applied.
@@ -299,20 +316,13 @@ export async function setupPluginConfig(params: {
prompter: WizardPrompter;
workspaceDir?: string;
}): Promise<OpenClawConfig> {
const { loadPluginManifestRegistryForPluginRegistry } = await loadPluginRegistryModule();
const registry = loadPluginManifestRegistryForPluginRegistry({
const manifestPlugins = await listEnabledConfigurableManifestPlugins({
config: params.config,
workspaceDir: params.workspaceDir,
includeDisabled: true,
});
const unconfigured = discoverUnconfiguredPlugins({
manifestPlugins: registry.plugins.filter((p) => {
// Only show enabled plugins
const entry = params.config.plugins?.entries?.[p.id];
// Plugin is discoverable if it's enabled or enabledByDefault and not denied
return p.enabledByDefault || entry?.enabled === true;
}),
manifestPlugins,
config: params.config,
});
@@ -362,18 +372,13 @@ export async function configurePluginConfig(params: {
prompter: WizardPrompter;
workspaceDir?: string;
}): Promise<OpenClawConfig> {
const { loadPluginManifestRegistryForPluginRegistry } = await loadPluginRegistryModule();
const registry = loadPluginManifestRegistryForPluginRegistry({
const manifestPlugins = await listEnabledConfigurableManifestPlugins({
config: params.config,
workspaceDir: params.workspaceDir,
includeDisabled: true,
});
const configurable = discoverConfigurablePlugins({
manifestPlugins: registry.plugins.filter((p) => {
const entry = params.config.plugins?.entries?.[p.id];
return p.enabledByDefault || entry?.enabled === true;
}),
manifestPlugins,
});
if (configurable.length === 0) {