From 2f44ffc8a75f78da3f593da831152209b40c4b72 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 08:41:14 +0100 Subject: [PATCH] refactor: route plugin metadata consumers through snapshots --- src/agents/skills/plugin-skills.test.ts | 18 +++++ src/agents/skills/plugin-skills.ts | 39 ++-------- src/channels/plugins/bundled.ts | 18 +++-- src/channels/plugins/read-only.ts | 62 +++++++++++++++- src/cli/plugins-command-helpers.ts | 11 ++- .../shared/legacy-web-search-migrate.test.ts | 6 +- .../shared/legacy-web-search-migrate.ts | 73 +++++++++++-------- .../missing-configured-plugin-install.test.ts | 8 +- .../missing-configured-plugin-install.ts | 14 ++-- .../shared/plugin-tool-allowlist-warnings.ts | 9 +-- .../doctor/shared/stale-plugin-config.ts | 9 +-- .../models/list.manifest-catalog.test.ts | 29 +++++--- src/commands/models/list.manifest-catalog.ts | 26 ++++--- src/hooks/plugin-hooks.ts | 19 +++-- src/trajectory/metadata.test.ts | 10 +++ src/trajectory/metadata.ts | 11 ++- src/wizard/setup.plugin-config.test.ts | 10 +++ src/wizard/setup.plugin-config.ts | 47 ++++++------ 18 files changed, 257 insertions(+), 162 deletions(-) diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 8e965d3abd3..3e1a34c54fa 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -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: [] }); }); diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 66f92b0b6a1..df4b24826ab 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -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> { - 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; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 0e4f2e0da93..4d7f3a10447 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -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); } diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 00b9fcdf771..c2ba51767ed 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -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]); } } diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 3e0f0935e88..2f59aa735bc 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -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, })), diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.test.ts b/src/commands/doctor/shared/legacy-web-search-migrate.test.ts index 52ca27a6b0b..a28f51d22ee 100644 --- a/src/commands/doctor/shared/legacy-web-search-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-web-search-migrate.test.ts @@ -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 { diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.ts b/src/commands/doctor/shared/legacy-web-search-migrate.ts index 5089a518a90..45e2ab42fa7 100644 --- a/src/commands/doctor/shared/legacy-web-search-migrate.ts +++ b/src/commands/doctor/shared/legacy-web-search-migrate.ts @@ -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 { + const owners = new Map(); + 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 = 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 { - return new Set(getLegacyWebSearchProviderIds()); +function getLegacyWebSearchProviderIdSet(owners: ReadonlyMap): Set { + 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, +): 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, +): { 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(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( raw: T, + owners: ReadonlyMap, ): { config: T; changes: string[]; @@ -186,7 +201,7 @@ function normalizeLegacyWebSearchConfigRecord( 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( } web.search = nextSearch; - const globalSearchMigration = resolveLegacyGlobalWebSearchMigration(search); + const globalSearchMigration = resolveLegacyGlobalWebSearchMigration(search, owners); if (globalSearchMigration) { migratePluginWebSearchConfig({ root: nextRoot, @@ -207,7 +222,7 @@ function normalizeLegacyWebSearchConfigRecord( }); } - 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( 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; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 82583f3f2cd..803136cc3ae 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -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: [], }); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 2719630dea1..ff75945f4cd 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -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( diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 9a250c92119..07263fd3781 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -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>(); diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index eec3ca8ae25..77be5b0c127 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -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(); for (const pluginId of Object.keys(cfg.plugins?.installs ?? {})) { diff --git a/src/commands/models/list.manifest-catalog.test.ts b/src/commands/models/list.manifest-catalog.test.ts index 7fc693baeac..c0153bdaa7e 100644 --- a/src/commands/models/list.manifest-catalog.test.ts +++ b/src/commands/models/list.manifest-catalog.test.ts @@ -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( diff --git a/src/commands/models/list.manifest-catalog.ts b/src/commands/models/list.manifest-catalog.ts index 156e96d3ec2..92a11144d44 100644 --- a/src/commands/models/list.manifest-catalog.ts +++ b/src/commands/models/list.manifest-catalog.ts @@ -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, diff --git a/src/hooks/plugin-hooks.ts b/src/hooks/plugin-hooks.ts index ea1692a3268..523f1f8e974 100644 --- a/src/hooks/plugin-hooks.ts +++ b/src/hooks/plugin-hooks.ts @@ -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(); diff --git a/src/trajectory/metadata.test.ts b/src/trajectory/metadata.test.ts index a1894fe784b..ec56b2fc4a8 100644 --- a/src/trajectory/metadata.test.ts +++ b/src/trajectory/metadata.test.ts @@ -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(() => { diff --git a/src/trajectory/metadata.ts b/src/trajectory/metadata.ts index 7a741e1eb95..f3bbcf22a79 100644 --- a/src/trajectory/metadata.ts +++ b/src/trajectory/metadata.ts @@ -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, diff --git a/src/wizard/setup.plugin-config.test.ts b/src/wizard/setup.plugin-config.test.ts index f6d20517b5f..9dc018482bf 100644 --- a/src/wizard/setup.plugin-config.test.ts +++ b/src/wizard/setup.plugin-config.test.ts @@ -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, diff --git a/src/wizard/setup.plugin-config.ts b/src/wizard/setup.plugin-config.ts index baf025a023c..8bafb79cf23 100644 --- a/src/wizard/setup.plugin-config.ts +++ b/src/wizard/setup.plugin-config.ts @@ -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 | undefined; +let pluginMetadataSnapshotModulePromise: Promise | undefined; -function loadPluginRegistryModule(): Promise { - pluginRegistryModulePromise ??= import("../plugins/plugin-registry.js"); - return pluginRegistryModulePromise; +function loadPluginMetadataSnapshotModule(): Promise { + 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 { + 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 { - 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 { - 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) {