diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 77571b7d0b6..5c6ab6c8d0f 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -43,11 +43,21 @@ vi.mock("../channels/config-presence.js", () => ({ hasMeaningfulChannelConfig, })); -vi.mock("./manifest-registry-installed.js", () => ({ - loadPluginManifestRegistryForInstalledIndex, -})); +vi.mock("./manifest-registry-installed.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistryForInstalledIndex, + }; +}); -vi.mock("./plugin-registry-snapshot.js", () => ({ loadPluginRegistrySnapshot })); +vi.mock("./plugin-registry-snapshot.js", () => ({ + loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata: (params: unknown) => ({ + snapshot: loadPluginRegistrySnapshot(params), + diagnostics: [], + }), +})); vi.mock("./plugin-registry-contributions.js", async (importOriginal) => { const actual = await importOriginal(); @@ -62,6 +72,7 @@ import { listConfiguredAnnounceChannelIdsForConfig, listConfiguredChannelIdsForReadOnlyScope, listExplicitConfiguredChannelIdsForConfig, + loadGatewayStartupPluginPlan, resolveConfiguredChannelPresencePolicy, resolveConfiguredDeferredChannelPluginIds, resolveConfiguredChannelPluginIds, @@ -854,6 +865,31 @@ describe("resolveGatewayStartupPluginIds", () => { ).toEqual([]); }); + it("loads channel, deferred, and startup plugin ids from one manifest registry", () => { + const registry = createManifestRegistryFixture(); + const index = createInstalledPluginIndexFixture(registry); + loadPluginRegistrySnapshot.mockReset().mockReturnValue(index); + loadPluginManifestRegistryForInstalledIndex.mockReset().mockReturnValue(registry); + + const plan = loadGatewayStartupPluginPlan({ + config: { + channels: { + "demo-channel": { + token: "configured", + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }); + + expect(plan.channelPluginIds).toContain("demo-channel"); + expect(plan.pluginIds).toContain("demo-channel"); + expect(plan.configuredDeferredChannelPluginIds).toEqual([]); + expect(loadPluginRegistrySnapshot).toHaveBeenCalledOnce(); + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); + }); + it("does not treat explicitly disabled stale channel config as deferred startup intent", () => { useManifestRegistryFixture(createManifestRegistryFixtureWithWorkspaceDemoChannel()); diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 56e0da57898..78749006620 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -17,6 +17,9 @@ export { resolveChannelPluginIdsFromRegistry, resolveConfiguredDeferredChannelPluginIds, resolveConfiguredDeferredChannelPluginIdsFromRegistry, + loadGatewayStartupPluginPlan, resolveGatewayStartupPluginIds, + resolveGatewayStartupPluginPlanFromRegistry, resolveGatewayStartupPluginIdsFromRegistry, + type GatewayStartupPluginPlan, } from "./gateway-startup-plugin-ids.js"; diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index bbe8053f5c2..ed7f896555e 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -11,7 +11,7 @@ import { } from "./plugin-control-plane-context.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; -export function resolvePluginMetadataSnapshotConfigFingerprint( +export function resolvePluginMetadataControlPlaneFingerprint( config?: OpenClawConfig, options: Omit = {}, ): string { @@ -30,7 +30,7 @@ export function setCurrentPluginMetadataSnapshot( setCurrentPluginMetadataSnapshotState( snapshot, snapshot - ? resolvePluginMetadataSnapshotConfigFingerprint(options.config, { + ? resolvePluginMetadataControlPlaneFingerprint(options.config, { env: options.env, index: snapshot.index, policyHash: snapshot.policyHash, @@ -63,15 +63,12 @@ export function getCurrentPluginMetadataSnapshot( return undefined; } if (params.config) { - const requestedConfigFingerprint = resolvePluginMetadataSnapshotConfigFingerprint( - params.config, - { - env: params.env, - index: snapshot.index, - policyHash: snapshot.policyHash, - workspaceDir: params.workspaceDir, - }, - ); + const requestedConfigFingerprint = resolvePluginMetadataControlPlaneFingerprint(params.config, { + env: params.env, + index: snapshot.index, + policyHash: snapshot.policyHash, + workspaceDir: params.workspaceDir, + }); if (configFingerprint && configFingerprint !== requestedConfigFingerprint) { return undefined; } diff --git a/src/plugins/effective-plugin-ids.ts b/src/plugins/effective-plugin-ids.ts index 7924203e8bc..7daefd72548 100644 --- a/src/plugins/effective-plugin-ids.ts +++ b/src/plugins/effective-plugin-ids.ts @@ -7,8 +7,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { listExplicitConfiguredChannelIdsForConfig, + loadGatewayStartupPluginPlan, resolveConfiguredChannelPluginIds, - resolveGatewayStartupPluginIds, } from "./channel-plugin-ids.js"; import { normalizePluginsConfig } from "./config-state.js"; import { passesManifestOwnerBasePolicy } from "./manifest-owner-policy.js"; @@ -155,12 +155,12 @@ export function resolveEffectivePluginIds(params: { })) { ids.add(pluginId); } - for (const pluginId of resolveGatewayStartupPluginIds({ + for (const pluginId of loadGatewayStartupPluginPlan({ config: effectiveConfig, activationSourceConfig: params.config, workspaceDir: params.workspaceDir, env: params.env, - })) { + }).pluginIds) { ids.add(pluginId); } return [...ids].toSorted((left, right) => left.localeCompare(right)); diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 5c08fb8b8fc..94e04cfaf9a 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -15,13 +15,23 @@ import { hasExplicitChannelConfig } from "./channel-presence-policy.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js"; -import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; +import { + isPluginMetadataSnapshotCompatible, + loadPluginMetadataSnapshot, + type PluginMetadataSnapshot, +} from "./plugin-metadata-snapshot.js"; import { createPluginRegistryIdNormalizer, normalizePluginsConfigWithRegistry, } from "./plugin-registry-contributions.js"; -import { loadPluginRegistrySnapshot } from "./plugin-registry-snapshot.js"; +import type { PluginRegistrySnapshot } from "./plugin-registry-snapshot.js"; + +export type GatewayStartupPluginPlan = { + channelPluginIds: readonly string[]; + configuredDeferredChannelPluginIds: readonly string[]; + pluginIds: readonly string[]; +}; function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -235,19 +245,7 @@ export function resolveChannelPluginIds(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] { - const index = loadPluginRegistrySnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); - const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ - index, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - includeDisabled: true, - }); - return resolveChannelPluginIdsFromRegistry({ manifestRegistry }); + return [...loadGatewayStartupPluginPlan(params).channelPluginIds]; } export function resolveChannelPluginIdsFromRegistry(params: { @@ -262,7 +260,7 @@ export function resolveChannelPluginIdsFromRegistry(params: { export function resolveConfiguredDeferredChannelPluginIdsFromRegistry(params: { config: OpenClawConfig; env: NodeJS.ProcessEnv; - index: ReturnType; + index: PluginRegistrySnapshot; manifestRegistry: PluginManifestRegistry; }): string[] { const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env)); @@ -302,33 +300,25 @@ export function resolveConfiguredDeferredChannelPluginIds(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] { - const index = loadPluginRegistrySnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); - const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ - index, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - includeDisabled: true, - }); - return resolveConfiguredDeferredChannelPluginIdsFromRegistry({ - config: params.config, - env: params.env, - index, - manifestRegistry, - }); + return [...loadGatewayStartupPluginPlan(params).configuredDeferredChannelPluginIds]; } -export function resolveGatewayStartupPluginIdsFromRegistry(params: { +export function resolveGatewayStartupPluginPlanFromRegistry(params: { config: OpenClawConfig; activationSourceConfig?: OpenClawConfig; env: NodeJS.ProcessEnv; - index: ReturnType; + index: PluginRegistrySnapshot; manifestRegistry: PluginManifestRegistry; -}): string[] { +}): GatewayStartupPluginPlan { + const channelPluginIds = resolveChannelPluginIdsFromRegistry({ + manifestRegistry: params.manifestRegistry, + }); + const configuredDeferredChannelPluginIds = resolveConfiguredDeferredChannelPluginIdsFromRegistry({ + config: params.config, + env: params.env, + index: params.index, + manifestRegistry: params.manifestRegistry, + }); const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env)); const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, params.index, { manifestRegistry: params.manifestRegistry, @@ -358,7 +348,7 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: { manifestRegistry: params.manifestRegistry, }), }); - return params.index.plugins + const pluginIds = params.index.plugins .filter((plugin) => { const manifest = findManifestPlugin(manifestLookup, plugin.pluginId); if ( @@ -427,6 +417,57 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: { return activationState.source === "explicit" || activationState.source === "default"; }) .map((plugin) => plugin.pluginId); + return { + channelPluginIds, + configuredDeferredChannelPluginIds, + pluginIds, + }; +} + +export function resolveGatewayStartupPluginIdsFromRegistry(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + env: NodeJS.ProcessEnv; + index: PluginRegistrySnapshot; + manifestRegistry: PluginManifestRegistry; +}): string[] { + return [...resolveGatewayStartupPluginPlanFromRegistry(params).pluginIds]; +} + +export function loadGatewayStartupPluginPlan(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + index?: PluginRegistrySnapshot; + metadataSnapshot?: PluginMetadataSnapshot; +}): GatewayStartupPluginPlan { + const snapshotConfig = params.activationSourceConfig ?? params.config; + const metadataSnapshot = + params.metadataSnapshot && + isPluginMetadataSnapshotCompatible({ + snapshot: params.metadataSnapshot, + config: snapshotConfig, + env: params.env, + workspaceDir: params.workspaceDir, + index: params.index, + }) + ? params.metadataSnapshot + : loadPluginMetadataSnapshot({ + config: snapshotConfig, + workspaceDir: params.workspaceDir, + env: params.env, + ...(params.index ? { index: params.index } : {}), + }); + return resolveGatewayStartupPluginPlanFromRegistry({ + config: params.config, + ...(params.activationSourceConfig !== undefined + ? { activationSourceConfig: params.activationSourceConfig } + : {}), + env: params.env, + index: metadataSnapshot.index, + manifestRegistry: metadataSnapshot.manifestRegistry, + }); } export function resolveGatewayStartupPluginIds(params: { @@ -435,25 +476,5 @@ export function resolveGatewayStartupPluginIds(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] { - const index = loadPluginRegistrySnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); - const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ - index, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - includeDisabled: true, - }); - return resolveGatewayStartupPluginIdsFromRegistry({ - config: params.config, - ...(params.activationSourceConfig !== undefined - ? { activationSourceConfig: params.activationSourceConfig } - : {}), - env: params.env, - index, - manifestRegistry, - }); + return [...loadGatewayStartupPluginPlan(params).pluginIds]; } diff --git a/src/plugins/plugin-lookup-table.ts b/src/plugins/plugin-lookup-table.ts index ab09e15057e..3b4089b0a27 100644 --- a/src/plugins/plugin-lookup-table.ts +++ b/src/plugins/plugin-lookup-table.ts @@ -1,8 +1,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { - resolveChannelPluginIdsFromRegistry, - resolveConfiguredDeferredChannelPluginIdsFromRegistry, - resolveGatewayStartupPluginIdsFromRegistry, + resolveGatewayStartupPluginPlanFromRegistry, + type GatewayStartupPluginPlan, } from "./channel-plugin-ids.js"; import { hashJson } from "./installed-plugin-index-hash.js"; import { @@ -15,11 +14,7 @@ import type { PluginRegistrySnapshot } from "./plugin-registry-snapshot.js"; export type PluginLookUpTableOwnerMaps = PluginMetadataSnapshotOwnerMaps; -export type PluginLookUpTableStartupPlan = { - channelPluginIds: readonly string[]; - configuredDeferredChannelPluginIds: readonly string[]; - pluginIds: readonly string[]; -}; +export type PluginLookUpTableStartupPlan = GatewayStartupPluginPlan; export type PluginLookUpTableMetrics = { registrySnapshotMs: number; @@ -72,14 +67,7 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug }); const { index, manifestRegistry } = metadataSnapshot; const startupPlanStartedAt = performance.now(); - const channelPluginIds = resolveChannelPluginIdsFromRegistry({ manifestRegistry }); - const configuredDeferredChannelPluginIds = resolveConfiguredDeferredChannelPluginIdsFromRegistry({ - config: params.config, - env: params.env, - index, - manifestRegistry, - }); - const pluginIds = resolveGatewayStartupPluginIdsFromRegistry({ + const startup = resolveGatewayStartupPluginPlanFromRegistry({ config: params.config, ...(params.activationSourceConfig !== undefined ? { activationSourceConfig: params.activationSourceConfig } @@ -89,11 +77,6 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug manifestRegistry, }); const startupPlanMs = performance.now() - startupPlanStartedAt; - const startup = { - channelPluginIds, - configuredDeferredChannelPluginIds, - pluginIds, - }; return { ...metadataSnapshot, @@ -112,8 +95,8 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug ...metadataSnapshot.metrics, startupPlanMs, totalMs: metadataSnapshot.metrics.totalMs + startupPlanMs, - startupPluginCount: pluginIds.length, - deferredChannelPluginCount: configuredDeferredChannelPluginIds.length, + startupPluginCount: startup.pluginIds.length, + deferredChannelPluginCount: startup.configuredDeferredChannelPluginIds.length, }, }; } diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index a55338b7810..fe67eb28037 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -23,7 +23,7 @@ export type { PluginMetadataSnapshotRegistryDiagnostic, } from "./plugin-metadata-snapshot.types.js"; -function resolvePluginMetadataSnapshotConfigFingerprint( +function resolvePluginMetadataControlPlaneFingerprint( params: Pick & { index?: InstalledPluginIndex; policyHash?: string; @@ -60,7 +60,7 @@ export function isPluginMetadataSnapshotCompatible(params: { params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) && (!params.snapshot.configFingerprint || params.snapshot.configFingerprint === - resolvePluginMetadataSnapshotConfigFingerprint({ + resolvePluginMetadataControlPlaneFingerprint({ config: params.config, env, index: params.index ?? params.snapshot.index, @@ -195,7 +195,7 @@ function loadPluginMetadataSnapshotImpl( return { policyHash: index.policyHash, - configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint({ + configFingerprint: resolvePluginMetadataControlPlaneFingerprint({ config: params.config, env: params.env, index, diff --git a/src/plugins/plugin-registry-contributions.ts b/src/plugins/plugin-registry-contributions.ts index 1b2107b8637..78311c8b60d 100644 --- a/src/plugins/plugin-registry-contributions.ts +++ b/src/plugins/plugin-registry-contributions.ts @@ -13,6 +13,7 @@ import type { PluginManifestRegistry, } from "./manifest-registry.js"; import { isPackageIncludedInCoreBundle } from "./manifest.js"; +import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { createPluginRegistryIdNormalizer, @@ -28,22 +29,10 @@ export { type PluginRegistryIdNormalizerOptions, } from "./plugin-registry-id-normalizer.js"; -export type PluginLookUpTable = { - index: PluginRegistrySnapshot; - manifestRegistry: PluginManifestRegistry; - plugins: readonly PluginManifestRecord[]; - normalizePluginId: (pluginId: string) => string; - owners: { - channels: ReadonlyMap; - channelConfigs: ReadonlyMap; - providers: ReadonlyMap; - modelCatalogProviders: ReadonlyMap; - cliBackends: ReadonlyMap; - setupProviders: ReadonlyMap; - commandAliases: ReadonlyMap; - contracts: ReadonlyMap; - }; -}; +export type PluginLookUpTable = Pick< + PluginMetadataSnapshot, + "index" | "manifestRegistry" | "plugins" | "normalizePluginId" | "owners" +>; export type PluginRegistryContributionOptions = LoadPluginRegistryParams & { includeDisabled?: boolean; diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index 78ad568e9b2..5f3d811455e 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -153,6 +153,41 @@ describe("bundled plugin public surface loader", () => { expect(createJiti).not.toHaveBeenCalled(); }); + it("does not cache missing public artifact locations", async () => { + vi.doMock("./native-module-require.js", () => ({ + tryNativeRequireJavaScriptModule: (modulePath: string) => ({ + ok: true, + moduleExport: { marker: path.basename(path.dirname(modulePath)) }, + }), + })); + vi.resetModules(); + + const publicSurfaceLoader = await importFreshModule< + typeof import("./public-surface-loader.js") + >(import.meta.url, "./public-surface-loader.js?scope=missing-location-retry"); + const tempRoot = createTempDir(); + const bundledPluginsDir = path.join(tempRoot, "dist"); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; + + expect( + publicSurfaceLoader.resolveBundledPluginPublicArtifactPath({ + dirName: "demo", + artifactBasename: "api.js", + }), + ).toBeNull(); + + const modulePath = path.join(bundledPluginsDir, "demo", "api.js"); + fs.mkdirSync(path.dirname(modulePath), { recursive: true }); + fs.writeFileSync(modulePath, 'export const marker = "demo";\n', "utf8"); + + expect( + publicSurfaceLoader.loadBundledPluginPublicArtifactModuleSync<{ marker: string }>({ + dirName: "demo", + artifactBasename: "api.js", + }).marker, + ).toBe("demo"); + }); + it("rejects public artifacts that change after boundary validation", async () => { const createJiti = vi.fn(() => vi.fn(() => ({ marker: "should-not-load" }))); vi.doMock("jiti", () => ({ diff --git a/src/plugins/public-surface-loader.ts b/src/plugins/public-surface-loader.ts index 9ec21204171..f7e880e09e0 100644 --- a/src/plugins/public-surface-loader.ts +++ b/src/plugins/public-surface-loader.ts @@ -25,7 +25,7 @@ const publicSurfaceLocationCache = new Map< { modulePath: string; boundaryRoot: string; - } | null + } >(); const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache(); @@ -84,11 +84,14 @@ function resolvePublicSurfaceLocation(params: { artifactBasename: string; }): { modulePath: string; boundaryRoot: string } | null { const key = createResolutionKey(params); - if (publicSurfaceLocationCache.has(key)) { - return publicSurfaceLocationCache.get(key) ?? null; + const cached = publicSurfaceLocationCache.get(key); + if (cached) { + return cached; } const resolved = resolvePublicSurfaceLocationUncached(params); - publicSurfaceLocationCache.set(key, resolved); + if (resolved) { + publicSurfaceLocationCache.set(key, resolved); + } return resolved; }