diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts new file mode 100644 index 00000000000..f0d17c600e1 --- /dev/null +++ b/src/plugins/installed-plugin-index.test.ts @@ -0,0 +1,279 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginCandidate } from "./discovery.js"; +import { + diffInstalledPluginIndexInvalidationReasons, + loadInstalledPluginIndex, + refreshInstalledPluginIndex, + resolveInstalledPluginContributions, +} from "./installed-plugin-index.js"; +import type { OpenClawPackageManifest } from "./manifest.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; + +vi.unmock("../version.js"); + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir() { + return makeTrackedTempDir("openclaw-installed-plugin-index", tempDirs); +} + +function writePluginManifest(rootDir: string, manifest: Record) { + fs.writeFileSync(path.join(rootDir, "openclaw.plugin.json"), JSON.stringify(manifest), "utf-8"); +} + +function writePackageJson(rootDir: string, packageJson: Record) { + fs.writeFileSync(path.join(rootDir, "package.json"), JSON.stringify(packageJson), "utf-8"); +} + +function writeRuntimeEntry(rootDir: string) { + fs.writeFileSync( + path.join(rootDir, "index.ts"), + "throw new Error('runtime entry should not load while building installed plugin index');\n", + "utf-8", + ); +} + +function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + ...overrides, + }; +} + +function createPluginCandidate(params: { + rootDir: string; + idHint?: string; + origin?: PluginCandidate["origin"]; + packageName?: string; + packageVersion?: string; + packageManifest?: OpenClawPackageManifest; +}): PluginCandidate { + return { + idHint: params.idHint ?? "demo", + source: path.join(params.rootDir, "index.ts"), + rootDir: params.rootDir, + origin: params.origin ?? "global", + packageName: params.packageName, + packageVersion: params.packageVersion, + packageDir: params.rootDir, + packageManifest: params.packageManifest, + }; +} + +function createRichPluginFixture(params: { packageVersion?: string } = {}) { + const rootDir = makeTempDir(); + writeRuntimeEntry(rootDir); + writePackageJson(rootDir, { + name: "@vendor/demo-plugin", + version: params.packageVersion ?? "1.2.3", + }); + writePluginManifest(rootDir, { + id: "demo", + name: "Demo", + configSchema: { type: "object" }, + providers: ["demo"], + channels: ["demo-chat"], + cliBackends: ["demo-cli"], + channelConfigs: { + "demo-chat": { + schema: { type: "object" }, + }, + }, + modelCatalog: { + providers: { + demo: { + models: [{ id: "demo-model" }], + }, + }, + discovery: { + demo: "static", + }, + }, + setup: { + providers: [{ id: "demo", envVars: ["DEMO_API_KEY"] }], + cliBackends: ["setup-cli"], + }, + commandAliases: [{ name: "demo-command" }], + contracts: { + tools: ["demo-tool"], + }, + providerAuthEnvVars: { + demo: ["DEMO_API_KEY"], + }, + channelEnvVars: { + "demo-chat": ["DEMO_CHAT_TOKEN"], + }, + activation: { + onProviders: ["demo"], + onChannels: ["demo-chat"], + }, + }); + return { + rootDir, + candidate: createPluginCandidate({ + rootDir, + packageName: "@vendor/demo-plugin", + packageVersion: params.packageVersion ?? "1.2.3", + packageManifest: { + install: { + npmSpec: "@vendor/demo-plugin@1.2.3", + expectedIntegrity: "sha512-demo", + defaultChoice: "npm", + }, + }, + }), + }; +} + +describe("installed plugin index", () => { + it("builds a runtime-free installed plugin snapshot from manifest and package metadata", () => { + const fixture = createRichPluginFixture(); + + const index = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + env: hermeticEnv(), + now: () => new Date("2026-04-25T12:00:00.000Z"), + }); + + expect(index).toMatchObject({ + version: 1, + generatedAt: "2026-04-25T12:00:00.000Z", + plugins: [ + { + pluginId: "demo", + packageName: "@vendor/demo-plugin", + packageVersion: "1.2.3", + origin: "global", + rootDir: fixture.rootDir, + enabled: true, + sourceFacts: { + defaultChoice: "npm", + npm: { + spec: "@vendor/demo-plugin@1.2.3", + packageName: "@vendor/demo-plugin", + selector: "1.2.3", + selectorKind: "exact-version", + exactVersion: true, + expectedIntegrity: "sha512-demo", + pinState: "exact-with-integrity", + }, + warnings: [], + }, + contributions: { + providers: ["demo"], + channels: ["demo-chat"], + channelConfigs: ["demo-chat"], + setupProviders: ["demo"], + cliBackends: ["demo-cli", "setup-cli"], + modelCatalogProviders: ["demo"], + commandAliases: ["demo-command"], + contracts: ["tools"], + }, + compat: [ + "activation-channel-hint", + "activation-provider-hint", + "channel-env-vars", + "provider-auth-env-vars", + ], + }, + ], + }); + expect(index.plugins[0]?.manifestHash).toMatch(/^[a-f0-9]{64}$/u); + expect(index.plugins[0]?.packageJsonHash).toMatch(/^[a-f0-9]{64}$/u); + expect(index.plugins[0]?.packageJsonPath).toBe(path.join(fixture.rootDir, "package.json")); + + const contributions = resolveInstalledPluginContributions(index); + expect(contributions.providers.get("demo")).toEqual(["demo"]); + expect(contributions.channels.get("demo-chat")).toEqual(["demo"]); + expect(contributions.contracts.get("tools")).toEqual(["demo"]); + }); + + it("marks disabled plugins without dropping their cold contributions", () => { + const fixture = createRichPluginFixture(); + + const index = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + config: { + plugins: { + entries: { + demo: { + enabled: false, + }, + }, + }, + }, + env: hermeticEnv(), + }); + + expect(index.plugins[0]?.enabled).toBe(false); + expect(index.plugins[0]?.contributions.providers).toEqual(["demo"]); + }); + + it("tracks refresh reason without using the manifest cache", () => { + const fixture = createRichPluginFixture(); + + const index = refreshInstalledPluginIndex({ + reason: "manual", + candidates: [fixture.candidate], + env: hermeticEnv(), + }); + + expect(index.refreshReason).toBe("manual"); + }); + + it("diffs invalidation reasons for manifest, package, source, host, and compat changes", () => { + const fixture = createRichPluginFixture(); + const previous = loadInstalledPluginIndex({ + candidates: [fixture.candidate], + env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.25" }), + }); + + writePackageJson(fixture.rootDir, { + name: "@vendor/demo-plugin", + version: "1.2.4", + }); + writePluginManifest(fixture.rootDir, { + id: "demo", + configSchema: { type: "object" }, + providers: ["demo", "demo-next"], + }); + const current = { + ...loadInstalledPluginIndex({ + candidates: [ + { + ...fixture.candidate, + packageVersion: "1.2.4", + }, + ], + env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }), + }), + compatRegistryVersion: "different-compat-registry", + }; + + expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([ + "compat-registry-changed", + "host-contract-changed", + "stale-manifest", + "stale-package", + ]); + + const moved = { + ...current, + plugins: current.plugins.map((plugin) => ({ + ...plugin, + rootDir: path.join(plugin.rootDir, "moved"), + })), + }; + expect(diffInstalledPluginIndexInvalidationReasons(current, moved)).toContain("source-changed"); + }); +}); diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts new file mode 100644 index 00000000000..28e7693c1bc --- /dev/null +++ b/src/plugins/installed-plugin-index.ts @@ -0,0 +1,486 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.js"; +import { resolveCompatibilityHostVersion } from "../version.js"; +import { listPluginCompatRecords, type PluginCompatCode } from "./compat/registry.js"; +import { + normalizePluginsConfigWithResolver, + resolveEffectiveEnableState, +} from "./config-policy.js"; +import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { + describePluginInstallSource, + type PluginInstallSourceInfo, +} from "./install-source-info.js"; +import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, + type PluginManifestRegistry, +} from "./manifest-registry.js"; +import type { PluginDiagnostic } from "./manifest-types.js"; + +export const INSTALLED_PLUGIN_INDEX_VERSION = 1; + +export type InstalledPluginIndexRefreshReason = + | "missing" + | "stale-manifest" + | "stale-package" + | "source-changed" + | "host-contract-changed" + | "compat-registry-changed" + | "manual"; + +export type InstalledPluginIndexContributions = { + providers: readonly string[]; + channels: readonly string[]; + channelConfigs: readonly string[]; + setupProviders: readonly string[]; + cliBackends: readonly string[]; + modelCatalogProviders: readonly string[]; + commandAliases: readonly string[]; + contracts: readonly string[]; +}; + +export type InstalledPluginIndexRecord = { + pluginId: string; + packageName?: string; + packageVersion?: string; + sourceFacts?: PluginInstallSourceInfo; + manifestPath: string; + manifestHash: string; + packageJsonPath?: string; + packageJsonHash?: string; + rootDir: string; + origin: PluginManifestRecord["origin"]; + enabled: boolean; + contributions: InstalledPluginIndexContributions; + compat: readonly PluginCompatCode[]; +}; + +export type InstalledPluginIndex = { + version: typeof INSTALLED_PLUGIN_INDEX_VERSION; + hostContractVersion: string; + compatRegistryVersion: string; + generatedAt: string; + refreshReason?: InstalledPluginIndexRefreshReason; + plugins: readonly InstalledPluginIndexRecord[]; + diagnostics: readonly PluginDiagnostic[]; +}; + +export type InstalledPluginContributions = { + providers: ReadonlyMap; + channels: ReadonlyMap; + channelConfigs: ReadonlyMap; + setupProviders: ReadonlyMap; + cliBackends: ReadonlyMap; + modelCatalogProviders: ReadonlyMap; + commandAliases: ReadonlyMap; + contracts: ReadonlyMap; +}; + +export type LoadInstalledPluginIndexParams = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + cache?: boolean; + candidates?: PluginCandidate[]; + diagnostics?: PluginDiagnostic[]; + now?: () => Date; +}; + +export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & { + reason: InstalledPluginIndexRefreshReason; +}; + +function hashString(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function hashJson(value: unknown): string { + return hashString(JSON.stringify(value)); +} + +function safeHashFile(params: { + filePath: string; + pluginId?: string; + diagnostics: PluginDiagnostic[]; + required: boolean; +}): string | undefined { + try { + return crypto.createHash("sha256").update(fs.readFileSync(params.filePath)).digest("hex"); + } catch (err) { + if (params.required) { + params.diagnostics.push({ + level: "warn", + ...(params.pluginId ? { pluginId: params.pluginId } : {}), + source: params.filePath, + message: `installed plugin index could not hash ${params.filePath}: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + return undefined; + } +} + +function sortUnique(values: readonly string[] | undefined): readonly string[] { + if (!values || values.length === 0) { + return []; + } + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).toSorted( + (left, right) => left.localeCompare(right), + ); +} + +function collectObjectKeys(value: Record | undefined): readonly string[] { + return sortUnique(value ? Object.keys(value) : []); +} + +function collectCommandAliasNames( + aliases: readonly PluginManifestCommandAlias[] | undefined, +): readonly string[] { + return sortUnique(aliases?.map((alias) => alias.name) ?? []); +} + +function collectContractKeys(record: PluginManifestRecord): readonly string[] { + const contracts = record.contracts; + if (!contracts) { + return []; + } + return sortUnique( + Object.entries(contracts).flatMap(([key, value]) => + Array.isArray(value) && value.length > 0 ? [key] : [], + ), + ); +} + +function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] { + const codes: PluginCompatCode[] = []; + if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) { + codes.push("provider-auth-env-vars"); + } + if (record.channelEnvVars && Object.keys(record.channelEnvVars).length > 0) { + codes.push("channel-env-vars"); + } + if (record.activation?.onProviders?.length) { + codes.push("activation-provider-hint"); + } + if (record.activation?.onChannels?.length) { + codes.push("activation-channel-hint"); + } + if (record.activation?.onCommands?.length) { + codes.push("activation-command-hint"); + } + if (record.activation?.onRoutes?.length) { + codes.push("activation-route-hint"); + } + if (record.activation?.onCapabilities?.length) { + codes.push("activation-capability-hint"); + } + return sortUnique(codes) as readonly PluginCompatCode[]; +} + +function buildContributions(record: PluginManifestRecord): InstalledPluginIndexContributions { + return { + providers: sortUnique(record.providers), + channels: sortUnique(record.channels), + channelConfigs: collectObjectKeys(record.channelConfigs), + setupProviders: sortUnique(record.setup?.providers?.map((provider) => provider.id) ?? []), + cliBackends: sortUnique([...(record.cliBackends ?? []), ...(record.setup?.cliBackends ?? [])]), + modelCatalogProviders: collectObjectKeys(record.modelCatalog?.providers), + commandAliases: collectCommandAliasNames(record.commandAliases), + contracts: collectContractKeys(record), + }; +} + +function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined { + if (!candidate?.packageDir) { + return undefined; + } + const packageJsonPath = path.join(candidate.packageDir, "package.json"); + return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined; +} + +function describePackageInstallSource( + candidate: PluginCandidate | undefined, +): PluginInstallSourceInfo | undefined { + const install = candidate?.packageManifest?.install; + if (!install) { + return undefined; + } + return describePluginInstallSource(install, { + expectedPackageName: candidate?.packageName, + }); +} + +function buildCandidateLookup( + candidates: readonly PluginCandidate[], +): Map { + const byRootDir = new Map(); + for (const candidate of candidates) { + byRootDir.set(candidate.rootDir, candidate); + } + return byRootDir; +} + +function resolveCompatRegistryVersion(): string { + return hashJson( + listPluginCompatRecords().map((record) => ({ + code: record.code, + status: record.status, + deprecated: record.deprecated, + warningStarts: record.warningStarts, + removeAfter: record.removeAfter, + replacement: record.replacement, + })), + ); +} + +function resolveRegistry(params: LoadInstalledPluginIndexParams): { + registry: PluginManifestRegistry; + candidates: readonly PluginCandidate[]; +} { + if (params.candidates) { + return { + candidates: params.candidates, + registry: loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: false, + env: params.env, + candidates: params.candidates, + diagnostics: params.diagnostics, + }), + }; + } + + const normalized = normalizePluginsConfigWithResolver(params.config?.plugins); + const discovery = discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + extraPaths: normalized.loadPaths, + cache: params.cache, + env: params.env, + }); + return { + candidates: discovery.candidates, + registry: loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: false, + env: params.env, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }), + }; +} + +function buildInstalledPluginIndex( + params: LoadInstalledPluginIndexParams & { refreshReason?: InstalledPluginIndexRefreshReason }, +): InstalledPluginIndex { + const env = params.env ?? process.env; + const { candidates, registry } = resolveRegistry(params); + const candidateByRootDir = buildCandidateLookup(candidates); + const normalizedConfig = normalizePluginsConfigWithResolver(params.config?.plugins); + const diagnostics: PluginDiagnostic[] = [...registry.diagnostics]; + const generatedAt = (params.now?.() ?? new Date()).toISOString(); + const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => { + const candidate = candidateByRootDir.get(record.rootDir); + const packageJsonPath = resolvePackageJsonPath(candidate); + const sourceFacts = describePackageInstallSource(candidate); + const manifestHash = + safeHashFile({ + filePath: record.manifestPath, + pluginId: record.id, + diagnostics, + required: true, + }) ?? ""; + const packageJsonHash = packageJsonPath + ? safeHashFile({ + filePath: packageJsonPath, + pluginId: record.id, + diagnostics, + required: false, + }) + : undefined; + const enabled = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedConfig, + rootConfig: params.config, + enabledByDefault: record.enabledByDefault, + }).enabled; + + const indexRecord: InstalledPluginIndexRecord = { + pluginId: record.id, + manifestPath: record.manifestPath, + manifestHash, + rootDir: record.rootDir, + origin: record.origin, + enabled, + contributions: buildContributions(record), + compat: collectCompatCodes(record), + }; + if (candidate?.packageName) { + indexRecord.packageName = candidate.packageName; + } + if (candidate?.packageVersion) { + indexRecord.packageVersion = candidate.packageVersion; + } + if (sourceFacts) { + indexRecord.sourceFacts = sourceFacts; + } + if (packageJsonPath) { + indexRecord.packageJsonPath = packageJsonPath; + } + if (packageJsonHash) { + indexRecord.packageJsonHash = packageJsonHash; + } + return indexRecord; + }); + + return { + version: INSTALLED_PLUGIN_INDEX_VERSION, + hostContractVersion: resolveCompatibilityHostVersion(env), + compatRegistryVersion: resolveCompatRegistryVersion(), + generatedAt, + ...(params.refreshReason ? { refreshReason: params.refreshReason } : {}), + plugins, + diagnostics, + }; +} + +export function loadInstalledPluginIndex( + params: LoadInstalledPluginIndexParams = {}, +): InstalledPluginIndex { + return buildInstalledPluginIndex(params); +} + +export function refreshInstalledPluginIndex( + params: RefreshInstalledPluginIndexParams, +): InstalledPluginIndex { + return buildInstalledPluginIndex({ ...params, cache: false, refreshReason: params.reason }); +} + +function addContribution( + target: Map, + contributionId: string, + pluginId: string, +): void { + const existing = target.get(contributionId); + if (existing) { + existing.push(pluginId); + } else { + target.set(contributionId, [pluginId]); + } +} + +function freezeContributionMap( + source: Map, +): ReadonlyMap { + const frozen = new Map(); + for (const [key, pluginIds] of source) { + frozen.set(key, sortUnique(pluginIds)); + } + return frozen; +} + +export function resolveInstalledPluginContributions( + index: InstalledPluginIndex, +): InstalledPluginContributions { + const providers = new Map(); + const channels = new Map(); + const channelConfigs = new Map(); + const setupProviders = new Map(); + const cliBackends = new Map(); + const modelCatalogProviders = new Map(); + const commandAliases = new Map(); + const contracts = new Map(); + + for (const plugin of index.plugins) { + for (const provider of plugin.contributions.providers) { + addContribution(providers, provider, plugin.pluginId); + } + for (const channel of plugin.contributions.channels) { + addContribution(channels, channel, plugin.pluginId); + } + for (const channelConfig of plugin.contributions.channelConfigs) { + addContribution(channelConfigs, channelConfig, plugin.pluginId); + } + for (const setupProvider of plugin.contributions.setupProviders) { + addContribution(setupProviders, setupProvider, plugin.pluginId); + } + for (const cliBackend of plugin.contributions.cliBackends) { + addContribution(cliBackends, cliBackend, plugin.pluginId); + } + for (const modelCatalogProvider of plugin.contributions.modelCatalogProviders) { + addContribution(modelCatalogProviders, modelCatalogProvider, plugin.pluginId); + } + for (const commandAlias of plugin.contributions.commandAliases) { + addContribution(commandAliases, commandAlias, plugin.pluginId); + } + for (const contract of plugin.contributions.contracts) { + addContribution(contracts, contract, plugin.pluginId); + } + } + + return { + providers: freezeContributionMap(providers), + channels: freezeContributionMap(channels), + channelConfigs: freezeContributionMap(channelConfigs), + setupProviders: freezeContributionMap(setupProviders), + cliBackends: freezeContributionMap(cliBackends), + modelCatalogProviders: freezeContributionMap(modelCatalogProviders), + commandAliases: freezeContributionMap(commandAliases), + contracts: freezeContributionMap(contracts), + }; +} + +export function diffInstalledPluginIndexInvalidationReasons( + previous: InstalledPluginIndex, + current: InstalledPluginIndex, +): readonly InstalledPluginIndexRefreshReason[] { + const reasons = new Set(); + if (previous.version !== current.version) { + reasons.add("missing"); + } + if (previous.hostContractVersion !== current.hostContractVersion) { + reasons.add("host-contract-changed"); + } + if (previous.compatRegistryVersion !== current.compatRegistryVersion) { + reasons.add("compat-registry-changed"); + } + + const previousByPluginId = new Map(previous.plugins.map((plugin) => [plugin.pluginId, plugin])); + const currentByPluginId = new Map(current.plugins.map((plugin) => [plugin.pluginId, plugin])); + for (const [pluginId, previousPlugin] of previousByPluginId) { + const currentPlugin = currentByPluginId.get(pluginId); + if (!currentPlugin) { + reasons.add("source-changed"); + continue; + } + if ( + previousPlugin.rootDir !== currentPlugin.rootDir || + previousPlugin.manifestPath !== currentPlugin.manifestPath + ) { + reasons.add("source-changed"); + } + if (previousPlugin.manifestHash !== currentPlugin.manifestHash) { + reasons.add("stale-manifest"); + } + if ( + previousPlugin.packageVersion !== currentPlugin.packageVersion || + previousPlugin.packageJsonHash !== currentPlugin.packageJsonHash + ) { + reasons.add("stale-package"); + } + } + for (const pluginId of currentByPluginId.keys()) { + if (!previousByPluginId.has(pluginId)) { + reasons.add("source-changed"); + } + } + + return Array.from(reasons).toSorted((left, right) => left.localeCompare(right)); +}