diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index 74be88f3afe..f2fc3d38fdc 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -20,8 +20,14 @@ const repairBundledRuntimeDepsInstallRootAsync = vi.hoisted(() => const resolveBundledRuntimeDependencyPackageInstallRoot = vi.hoisted(() => vi.fn((_packageRoot: string, _params: unknown) => "/runtime"), ); -const resolveConfiguredDeferredChannelPluginIds = vi.hoisted(() => vi.fn((_params: unknown) => [])); -const resolveGatewayStartupPluginIds = vi.hoisted(() => vi.fn((_params: unknown) => ["telegram"])); +const loadPluginLookUpTable = vi.hoisted(() => + vi.fn((_params: unknown) => ({ + startup: { + configuredDeferredChannelPluginIds: [], + pluginIds: ["telegram"], + }, + })), +); const resolveOpenClawPackageRootSync = vi.hoisted(() => vi.fn((_params: unknown) => "/package")); const runChannelPluginStartupMaintenance = vi.hoisted(() => vi.fn(async (_params: unknown) => undefined), @@ -65,10 +71,8 @@ vi.mock("../plugins/bundled-runtime-deps.js", () => ({ scanBundledPluginRuntimeDeps: (params: unknown) => scanBundledPluginRuntimeDeps(params), })); -vi.mock("../plugins/channel-plugin-ids.js", () => ({ - resolveConfiguredDeferredChannelPluginIds: (params: unknown) => - resolveConfiguredDeferredChannelPluginIds(params), - resolveGatewayStartupPluginIds: (params: unknown) => resolveGatewayStartupPluginIds(params), +vi.mock("../plugins/plugin-lookup-table.js", () => ({ + loadPluginLookUpTable: (params: unknown) => loadPluginLookUpTable(params), })); vi.mock("../plugins/registry.js", () => ({ @@ -112,8 +116,12 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { loadGatewayStartupPlugins.mockClear(); repairBundledRuntimeDepsInstallRootAsync.mockReset().mockResolvedValue({}); resolveBundledRuntimeDependencyPackageInstallRoot.mockClear(); - resolveConfiguredDeferredChannelPluginIds.mockClear(); - resolveGatewayStartupPluginIds.mockClear().mockReturnValue(["telegram"]); + loadPluginLookUpTable.mockClear().mockReturnValue({ + startup: { + configuredDeferredChannelPluginIds: [], + pluginIds: ["telegram"], + }, + }); resolveOpenClawPackageRootSync.mockClear().mockReturnValue("/package"); runChannelPluginStartupMaintenance.mockClear(); runStartupSessionMigration.mockClear(); @@ -143,6 +151,7 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => { }); expect(loadGatewayStartupPlugins).toHaveBeenCalledOnce(); + expect(loadPluginLookUpTable).toHaveBeenCalledOnce(); expect(scanBundledPluginRuntimeDeps).toHaveBeenCalledWith( expect.objectContaining({ selectedPluginIds: ["telegram"], diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index b3999ac3cca..93e8d5e1dd6 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -9,10 +9,7 @@ import { resolveBundledRuntimeDependencyPackageInstallRoot, scanBundledPluginRuntimeDeps, } from "../plugins/bundled-runtime-deps.js"; -import { - resolveConfiguredDeferredChannelPluginIds, - resolveGatewayStartupPluginIds, -} from "../plugins/channel-plugin-ids.js"; +import { loadPluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { listGatewayMethods } from "./server-methods-list.js"; @@ -139,21 +136,18 @@ export async function prepareGatewayPluginBootstrap(params: { }).config; const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfigAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfigAtStart, defaultAgentId); - const deferredConfiguredChannelPluginIds = params.minimalTestGateway - ? [] - : resolveConfiguredDeferredChannelPluginIds({ - config: gatewayPluginConfigAtStart, - workspaceDir: defaultWorkspaceDir, - env: process.env, - }); - const startupPluginIds = params.minimalTestGateway - ? [] - : resolveGatewayStartupPluginIds({ + const pluginLookUpTable = params.minimalTestGateway + ? undefined + : loadPluginLookUpTable({ config: gatewayPluginConfigAtStart, activationSourceConfig: params.cfgAtStart, workspaceDir: defaultWorkspaceDir, env: process.env, }); + const deferredConfiguredChannelPluginIds = [ + ...(pluginLookUpTable?.startup.configuredDeferredChannelPluginIds ?? []), + ]; + const startupPluginIds = [...(pluginLookUpTable?.startup.pluginIds ?? [])]; const baseMethods = listGatewayMethods(); const emptyPluginRegistry = createEmptyPluginRegistry(); diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index 8dd6b483f1c..56e0da57898 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -14,6 +14,9 @@ export { export { resolveChannelPluginIds, + resolveChannelPluginIdsFromRegistry, resolveConfiguredDeferredChannelPluginIds, + resolveConfiguredDeferredChannelPluginIdsFromRegistry, resolveGatewayStartupPluginIds, + resolveGatewayStartupPluginIdsFromRegistry, } from "./gateway-startup-plugin-ids.js"; diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 09e7b627a73..aa44e9320a8 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -190,26 +190,63 @@ export function resolveChannelPluginIds(params: { env: params.env, includeDisabled: true, }); + return resolveChannelPluginIdsFromRegistry({ manifestRegistry }); +} + +export function resolveChannelPluginIdsFromRegistry(params: { + manifestRegistry: PluginManifestRegistry; +}): string[] { + const { manifestRegistry } = params; return manifestRegistry.plugins .filter((plugin) => plugin.channels.length > 0) .map((plugin) => plugin.id); } +export function resolveConfiguredDeferredChannelPluginIdsFromRegistry(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + index: ReturnType; + manifestRegistry: PluginManifestRegistry; +}): string[] { + const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env)); + if (configuredChannelIds.size === 0) { + return []; + } + const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, params.index); + const activationSource = { + plugins: pluginsConfig, + rootConfig: params.config, + }; + return params.index.plugins + .filter( + (plugin) => + hasConfiguredStartupChannel({ + plugin, + manifestRegistry: params.manifestRegistry, + configuredChannelIds, + }) && + plugin.startup.deferConfiguredChannelFullLoadUntilAfterListen && + canStartConfiguredChannelPlugin({ + plugin, + config: params.config, + pluginsConfig, + activationSource, + manifestRegistry: params.manifestRegistry, + }), + ) + .map((plugin) => plugin.pluginId); +} + export function resolveConfiguredDeferredChannelPluginIds(params: { config: OpenClawConfig; workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] { - const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env)); - if (configuredChannelIds.size === 0) { - return []; - } const index = loadPluginRegistrySnapshot({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); - const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, index); const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ index, config: params.config, @@ -217,53 +254,30 @@ export function resolveConfiguredDeferredChannelPluginIds(params: { env: params.env, includeDisabled: true, }); - const activationSource = { - plugins: pluginsConfig, - rootConfig: params.config, - }; - return index.plugins - .filter( - (plugin) => - hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds }) && - plugin.startup.deferConfiguredChannelFullLoadUntilAfterListen && - canStartConfiguredChannelPlugin({ - plugin, - config: params.config, - pluginsConfig, - activationSource, - manifestRegistry, - }), - ) - .map((plugin) => plugin.pluginId); + return resolveConfiguredDeferredChannelPluginIdsFromRegistry({ + config: params.config, + env: params.env, + index, + manifestRegistry, + }); } -export function resolveGatewayStartupPluginIds(params: { +export function resolveGatewayStartupPluginIdsFromRegistry(params: { config: OpenClawConfig; activationSourceConfig?: OpenClawConfig; - workspaceDir?: string; env: NodeJS.ProcessEnv; + index: ReturnType; + manifestRegistry: PluginManifestRegistry; }): string[] { const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env)); - const index = loadPluginRegistrySnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); - const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, index); - const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ - index, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - includeDisabled: true, - }); + const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, params.index); // Startup must classify allowlist exceptions against the raw config snapshot, // not the auto-enabled effective snapshot, or configured-only channels can be // misclassified as explicit enablement. const activationSourceConfig = params.activationSourceConfig ?? params.config; const activationSourcePlugins = normalizePluginsConfigWithRegistry( activationSourceConfig.plugins, - index, + params.index, ); const activationSource = { plugins: activationSourcePlugins, @@ -276,17 +290,23 @@ export function resolveGatewayStartupPluginIds(params: { const memorySlotStartupPluginId = resolveMemorySlotStartupPluginId({ activationSourceConfig, activationSourcePlugins, - normalizePluginId: createPluginRegistryIdNormalizer(index), + normalizePluginId: createPluginRegistryIdNormalizer(params.index), }); - return index.plugins + return params.index.plugins .filter((plugin) => { - if (hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds })) { + if ( + hasConfiguredStartupChannel({ + plugin, + manifestRegistry: params.manifestRegistry, + configuredChannelIds, + }) + ) { return canStartConfiguredChannelPlugin({ plugin, config: params.config, pluginsConfig, activationSource, - manifestRegistry, + manifestRegistry: params.manifestRegistry, }); } if ( @@ -329,3 +349,32 @@ export function resolveGatewayStartupPluginIds(params: { }) .map((plugin) => plugin.pluginId); } + +export function resolveGatewayStartupPluginIds(params: { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + 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, + }); +} diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts new file mode 100644 index 00000000000..8dba780a4f7 --- /dev/null +++ b/src/plugins/plugin-lookup-table.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginRegistrySnapshot } from "./plugin-registry.js"; + +const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn()); +const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); + +vi.mock("../channels/config-presence.js", () => ({ + hasMeaningfulChannelConfig: (value: unknown) => + Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).some((key) => key !== "enabled"), + ), + listPotentialConfiguredChannelIds: ( + config: OpenClawConfig, + env: NodeJS.ProcessEnv, + options?: { includePersistedAuthState?: boolean }, + ) => listPotentialConfiguredChannelIds(config, env, options), +})); + +vi.mock("./manifest-registry-installed.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPluginManifestRegistryForInstalledIndex: (params: unknown) => + loadPluginManifestRegistryForInstalledIndex(params), + }; +}); + +function createManifestRecord( + plugin: Partial & Pick, +): PluginManifestRecord { + return { + name: plugin.id, + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + rootDir: `/plugins/${plugin.id}`, + source: `/plugins/${plugin.id}/index.js`, + manifestPath: `/plugins/${plugin.id}/openclaw.plugin.json`, + ...plugin, + }; +} + +function createIndex(plugins: readonly PluginManifestRecord[]): PluginRegistrySnapshot { + return { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, + policyHash: "policy", + generatedAtMs: 1, + installRecords: {}, + diagnostics: [], + plugins: plugins.map((plugin) => ({ + pluginId: plugin.id, + manifestPath: plugin.manifestPath, + manifestHash: `${plugin.id}-hash`, + rootDir: plugin.rootDir, + origin: plugin.origin, + enabled: true, + ...(plugin.enabledByDefault !== undefined + ? { enabledByDefault: plugin.enabledByDefault } + : {}), + startup: { + sidecar: false, + memory: false, + deferConfiguredChannelFullLoadUntilAfterListen: Boolean( + plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen, + ), + agentHarnesses: [], + }, + compat: [], + })), + }; +} + +describe("loadPluginLookUpTable", () => { + beforeEach(() => { + listPotentialConfiguredChannelIds + .mockReset() + .mockImplementation((config: OpenClawConfig) => Object.keys(config.channels ?? {})); + loadPluginManifestRegistryForInstalledIndex.mockReset(); + }); + + it("builds owner maps and startup ids from one installed manifest registry", async () => { + const plugins = [ + createManifestRecord({ + id: "telegram", + origin: "bundled", + channels: ["telegram"], + }), + createManifestRecord({ + id: "openai", + origin: "bundled", + providers: ["openai"], + cliBackends: ["codex-cli"], + setup: { + providers: [{ id: "openai" }], + }, + }), + ]; + const index = createIndex(plugins); + const manifestRegistry: PluginManifestRegistry = { + plugins, + diagnostics: [], + }; + loadPluginManifestRegistryForInstalledIndex.mockReturnValue(manifestRegistry); + const { loadPluginLookUpTable } = await import("./plugin-lookup-table.js"); + + const table = loadPluginLookUpTable({ + config: { + channels: { + telegram: { token: "configured" }, + }, + plugins: { + slots: { memory: "none" }, + }, + } as OpenClawConfig, + env: {}, + index, + }); + + expect(table.manifestRegistry).toBe(manifestRegistry); + expect(table.byPluginId.get("telegram")?.id).toBe("telegram"); + expect(table.owners.channels.get("telegram")).toEqual(["telegram"]); + expect(table.owners.providers.get("openai")).toEqual(["openai"]); + expect(table.owners.cliBackends.get("codex-cli")).toEqual(["openai"]); + expect(table.owners.setupProviders.get("openai")).toEqual(["openai"]); + expect(table.startup.channelPluginIds).toEqual(["telegram"]); + expect(table.startup.configuredDeferredChannelPluginIds).toEqual([]); + expect(table.startup.pluginIds).toEqual(["telegram"]); + }); +}); diff --git a/src/plugins/plugin-lookup-table.ts b/src/plugins/plugin-lookup-table.ts new file mode 100644 index 00000000000..6d158d50f64 --- /dev/null +++ b/src/plugins/plugin-lookup-table.ts @@ -0,0 +1,153 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + resolveChannelPluginIdsFromRegistry, + resolveConfiguredDeferredChannelPluginIdsFromRegistry, + resolveGatewayStartupPluginIdsFromRegistry, +} from "./channel-plugin-ids.js"; +import { hashJson } from "./installed-plugin-index-hash.js"; +import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginDiagnostic } from "./manifest-types.js"; +import { + loadPluginRegistrySnapshotWithMetadata, + type PluginRegistrySnapshot, + type PluginRegistrySnapshotDiagnostic, +} from "./plugin-registry.js"; + +export type PluginLookUpTableOwnerMaps = { + channels: ReadonlyMap; + providers: ReadonlyMap; + cliBackends: ReadonlyMap; + setupProviders: ReadonlyMap; +}; + +export type PluginLookUpTableStartupPlan = { + channelPluginIds: readonly string[]; + configuredDeferredChannelPluginIds: readonly string[]; + pluginIds: readonly string[]; +}; + +export type PluginLookUpTable = { + key: string; + index: PluginRegistrySnapshot; + registryDiagnostics: readonly PluginRegistrySnapshotDiagnostic[]; + manifestRegistry: PluginManifestRegistry; + plugins: readonly PluginManifestRecord[]; + diagnostics: readonly PluginDiagnostic[]; + byPluginId: ReadonlyMap; + owners: PluginLookUpTableOwnerMaps; + startup: PluginLookUpTableStartupPlan; +}; + +export type LoadPluginLookUpTableParams = { + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + index?: PluginRegistrySnapshot; +}; + +function appendOwner(owners: Map, ownedId: string, pluginId: string): void { + const existing = owners.get(ownedId); + if (existing) { + existing.push(pluginId); + return; + } + owners.set(ownedId, [pluginId]); +} + +function freezeOwnerMap(owners: Map): ReadonlyMap { + return new Map( + [...owners.entries()].map(([ownedId, pluginIds]) => [ownedId, Object.freeze([...pluginIds])]), + ); +} + +function buildOwnerMaps(plugins: readonly PluginManifestRecord[]): PluginLookUpTableOwnerMaps { + const channels = new Map(); + const providers = new Map(); + const cliBackends = new Map(); + const setupProviders = new Map(); + + for (const plugin of plugins) { + for (const channelId of plugin.channels) { + appendOwner(channels, channelId, plugin.id); + } + for (const providerId of plugin.providers) { + appendOwner(providers, providerId, plugin.id); + } + for (const cliBackendId of plugin.cliBackends) { + appendOwner(cliBackends, cliBackendId, plugin.id); + } + for (const setupProvider of plugin.setup?.providers ?? []) { + appendOwner(setupProviders, setupProvider.id, plugin.id); + } + } + + return { + channels: freezeOwnerMap(channels), + providers: freezeOwnerMap(providers), + cliBackends: freezeOwnerMap(cliBackends), + setupProviders: freezeOwnerMap(setupProviders), + }; +} + +export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): PluginLookUpTable { + const registryResult = loadPluginRegistrySnapshotWithMetadata({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + ...(params.index ? { index: params.index } : {}), + }); + const index = registryResult.snapshot; + const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({ + index, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeDisabled: true, + }); + const channelPluginIds = resolveChannelPluginIdsFromRegistry({ manifestRegistry }); + const configuredDeferredChannelPluginIds = resolveConfiguredDeferredChannelPluginIdsFromRegistry({ + config: params.config, + env: params.env, + index, + manifestRegistry, + }); + const pluginIds = resolveGatewayStartupPluginIdsFromRegistry({ + config: params.config, + ...(params.activationSourceConfig !== undefined + ? { activationSourceConfig: params.activationSourceConfig } + : {}), + env: params.env, + index, + manifestRegistry, + }); + const byPluginId = new Map(manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])); + const owners = buildOwnerMaps(manifestRegistry.plugins); + const startup = { + channelPluginIds, + configuredDeferredChannelPluginIds, + pluginIds, + }; + + return { + key: hashJson({ + policyHash: index.policyHash, + generatedAtMs: index.generatedAtMs, + plugins: index.plugins.map((plugin) => [ + plugin.pluginId, + plugin.manifestHash, + plugin.installRecordHash, + ]), + startup, + }), + index, + registryDiagnostics: registryResult.diagnostics, + manifestRegistry, + plugins: manifestRegistry.plugins, + diagnostics: [...index.diagnostics, ...manifestRegistry.diagnostics], + byPluginId, + owners, + startup, + }; +}