import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { listPluginCompatRecords, type PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { describePluginInstallSource, type PluginInstallSourceInfo, } from "./install-source-info.js"; import { loadPluginManifestRegistry, type PluginManifestRecord, type PluginManifestRegistry, } from "./manifest-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { safeRealpathSync } from "./path-safety.js"; import { hasKind } from "./slots.js"; export const INSTALLED_PLUGIN_INDEX_VERSION = 1; export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1; export const INSTALLED_PLUGIN_INDEX_WARNING = "DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead."; export type InstalledPluginIndexRefreshReason = | "missing" | "stale-manifest" | "stale-package" | "source-changed" | "policy-changed" | "migration" | "host-contract-changed" | "compat-registry-changed" | "manual"; export type InstalledPluginStartupInfo = { sidecar: boolean; memory: boolean; deferConfiguredChannelFullLoadUntilAfterListen: boolean; agentHarnesses: readonly string[]; }; export type InstalledPluginInstallRecordInfo = Pick< PluginInstallRecord, | "source" | "spec" | "sourcePath" | "installPath" | "version" | "resolvedName" | "resolvedVersion" | "resolvedSpec" | "integrity" | "shasum" | "resolvedAt" | "installedAt" | "clawhubUrl" | "clawhubPackage" | "clawhubFamily" | "clawhubChannel" | "marketplaceName" | "marketplaceSource" | "marketplacePlugin" >; export type InstalledPluginIndexRecord = { pluginId: string; packageName?: string; packageVersion?: string; /** * Legacy embedded install record accepted when reading earlier index files. * New index writes keep install records in InstalledPluginIndex.installRecords. */ installRecord?: InstalledPluginInstallRecordInfo; /** Hash of the top-level installRecords entry; used to detect source-changed invalidation. */ installRecordHash?: string; /** * Package-authored openclaw.install metadata. This describes catalog/package * install intent and must not be treated as the durable install record. */ packageInstall?: PluginInstallSourceInfo; manifestPath: string; manifestHash: string; format?: PluginManifestRecord["format"]; bundleFormat?: PluginManifestRecord["bundleFormat"]; source?: string; setupSource?: string; packageJson?: { path: string; hash: string; }; rootDir: string; origin: PluginManifestRecord["origin"]; enabled: boolean; enabledByDefault?: boolean; startup: InstalledPluginStartupInfo; compat: readonly PluginCompatCode[]; }; export type InstalledPluginIndex = { version: typeof INSTALLED_PLUGIN_INDEX_VERSION; warning?: string; hostContractVersion: string; compatRegistryVersion: string; migrationVersion: typeof INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION; policyHash: string; generatedAtMs: number; refreshReason?: InstalledPluginIndexRefreshReason; installRecords: Readonly>; plugins: readonly InstalledPluginIndexRecord[]; diagnostics: readonly PluginDiagnostic[]; }; export type LoadInstalledPluginIndexParams = { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; stateDir?: string; pluginIndexFilePath?: string; installRecords?: Record; 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 hasRuntimeContractSurface(record: PluginManifestRecord): boolean { return Boolean( record.providers.length > 0 || record.cliBackends.length > 0 || record.contracts?.speechProviders?.length || record.contracts?.mediaUnderstandingProviders?.length || record.contracts?.documentExtractors?.length || record.contracts?.imageGenerationProviders?.length || record.contracts?.videoGenerationProviders?.length || record.contracts?.musicGenerationProviders?.length || record.contracts?.webContentExtractors?.length || record.contracts?.webFetchProviders?.length || record.contracts?.webSearchProviders?.length || record.contracts?.memoryEmbeddingProviders?.length || hasKind(record.kind, "memory"), ); } function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupInfo { return { sidecar: record.channels.length === 0 && !hasRuntimeContractSurface(record), memory: hasKind(record.kind, "memory"), deferConfiguredChannelFullLoadUntilAfterListen: record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, agentHarnesses: sortUnique(record.activation?.onAgentHarnesses ?? []), }; } 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 resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined { if (!candidate?.packageDir) { return undefined; } const packageDir = safeRealpathSync(candidate.packageDir) ?? path.resolve(candidate.packageDir); const packageJsonPath = path.join(packageDir, "package.json"); return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined; } function resolvePackageJsonRelativePath(rootDir: string, packageJsonPath: string): string { const resolvedRootDir = safeRealpathSync(rootDir) ?? path.resolve(rootDir); const relativePath = path.relative(resolvedRootDir, packageJsonPath) || "package.json"; return relativePath.split(path.sep).join("/"); } function resolvePackageJsonRecord(params: { candidate: PluginCandidate | undefined; packageJsonPath: string | undefined; diagnostics: PluginDiagnostic[]; pluginId: string; }): InstalledPluginIndexRecord["packageJson"] | undefined { if (!params.candidate?.packageDir || !params.packageJsonPath) { return undefined; } const hash = safeHashFile({ filePath: params.packageJsonPath, pluginId: params.pluginId, diagnostics: params.diagnostics, required: false, }); if (!hash) { return undefined; } return { path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath), hash, }; } function describePackageInstallSource( candidate: PluginCandidate | undefined, ): PluginInstallSourceInfo | undefined { const install = candidate?.packageManifest?.install; if (!install) { return undefined; } return describePluginInstallSource(install, { expectedPackageName: candidate?.packageName, }); } function setInstallStringField>( target: InstalledPluginInstallRecordInfo, key: Key, value: PluginInstallRecord[Key], ): void { if (typeof value !== "string") { return; } const normalized = value.trim(); if (normalized) { target[key] = normalized as InstalledPluginInstallRecordInfo[Key]; } } function normalizeInstallRecord( record: PluginInstallRecord | undefined, ): InstalledPluginInstallRecordInfo | undefined { if (!record) { return undefined; } const normalized: InstalledPluginInstallRecordInfo = { source: record.source, }; setInstallStringField(normalized, "spec", record.spec); setInstallStringField(normalized, "sourcePath", record.sourcePath); setInstallStringField(normalized, "installPath", record.installPath); setInstallStringField(normalized, "version", record.version); setInstallStringField(normalized, "resolvedName", record.resolvedName); setInstallStringField(normalized, "resolvedVersion", record.resolvedVersion); setInstallStringField(normalized, "resolvedSpec", record.resolvedSpec); setInstallStringField(normalized, "integrity", record.integrity); setInstallStringField(normalized, "shasum", record.shasum); setInstallStringField(normalized, "resolvedAt", record.resolvedAt); setInstallStringField(normalized, "installedAt", record.installedAt); setInstallStringField(normalized, "clawhubUrl", record.clawhubUrl); setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage); setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily); setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel); setInstallStringField(normalized, "marketplaceName", record.marketplaceName); setInstallStringField(normalized, "marketplaceSource", record.marketplaceSource); setInstallStringField(normalized, "marketplacePlugin", record.marketplacePlugin); return normalized; } function restoreInstallRecord( record: InstalledPluginInstallRecordInfo | undefined, ): PluginInstallRecord | undefined { if (!record?.source) { return undefined; } return structuredClone(record) as PluginInstallRecord; } function normalizeInstallRecordMap( records: Record | undefined, ): Record { const normalized: Record = {}; for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) => left.localeCompare(right), )) { const installRecord = normalizeInstallRecord(record); if (installRecord) { normalized[pluginId] = installRecord; } } return normalized; } function restoreInstallRecordMap( records: Readonly> | undefined, ): Record { const restored: Record = {}; for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) => left.localeCompare(right), )) { const installRecord = restoreInstallRecord(record); if (installRecord) { restored[pluginId] = installRecord; } } return restored; } export function extractPluginInstallRecordsFromInstalledPluginIndex( index: InstalledPluginIndex | null | undefined, ): Record { if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) { return restoreInstallRecordMap(index.installRecords); } const records: Record = {}; for (const plugin of index?.plugins ?? []) { const record = restoreInstallRecord(plugin.installRecord); if (record) { records[plugin.pluginId] = record; } } return records; } 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, })), ); } export function resolveInstalledPluginIndexPolicyHash(config: OpenClawConfig | undefined): string { const normalized = normalizePluginsConfig(config?.plugins); const channelPolicy: Record = {}; const channels = config?.channels; if (channels && typeof channels === "object" && !Array.isArray(channels)) { for (const [channelId, value] of Object.entries(channels)) { if (value && typeof value === "object" && !Array.isArray(value)) { const enabled = (value as Record).enabled; if (typeof enabled === "boolean") { channelPolicy[channelId] = enabled; } } } } return hashJson({ plugins: { enabled: normalized.enabled, allow: normalized.allow, deny: normalized.deny, slots: normalized.slots, entries: Object.fromEntries( Object.entries(normalized.entries) .flatMap(([pluginId, entry]) => typeof entry.enabled === "boolean" ? [[pluginId, entry.enabled] as const] : [], ) .toSorted(([left], [right]) => left.localeCompare(right)), ), }, channels: Object.fromEntries( Object.entries(channelPolicy).toSorted(([left], [right]) => left.localeCompare(right)), ), }); } 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, installRecords: params.installRecords, }), }; } const normalized = normalizePluginsConfig(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, installRecords: params.installRecords, }), }; } function buildInstalledPluginIndex( params: LoadInstalledPluginIndexParams & { refreshReason?: InstalledPluginIndexRefreshReason }, ): InstalledPluginIndex { const env = params.env ?? process.env; const { candidates, registry } = resolveRegistry(params); const candidateByRootDir = buildCandidateLookup(candidates); const normalizedConfig = normalizePluginsConfig(params.config?.plugins); const diagnostics: PluginDiagnostic[] = [...registry.diagnostics]; const generatedAtMs = (params.now?.() ?? new Date()).getTime(); const installRecords = normalizeInstallRecordMap(params.installRecords); const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => { const candidate = candidateByRootDir.get(record.rootDir); const packageJsonPath = resolvePackageJsonPath(candidate); const installRecord = installRecords[record.id]; const packageInstall = describePackageInstallSource(candidate); const manifestHash = safeHashFile({ filePath: record.manifestPath, pluginId: record.id, diagnostics, required: true, }) ?? ""; const packageJson = resolvePackageJsonRecord({ candidate, packageJsonPath, diagnostics, pluginId: record.id, }); 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, source: record.source, rootDir: record.rootDir, origin: record.origin, enabled, startup: buildStartupInfo(record), compat: collectCompatCodes(record), }; if (record.format && record.format !== "openclaw") { indexRecord.format = record.format; } if (record.bundleFormat) { indexRecord.bundleFormat = record.bundleFormat; } if (record.enabledByDefault === true) { indexRecord.enabledByDefault = true; } if (record.setupSource) { indexRecord.setupSource = record.setupSource; } if (candidate?.packageName) { indexRecord.packageName = candidate.packageName; } if (candidate?.packageVersion) { indexRecord.packageVersion = candidate.packageVersion; } if (installRecord) { indexRecord.installRecordHash = hashJson(installRecord); } if (packageInstall) { indexRecord.packageInstall = packageInstall; } if (packageJson) { indexRecord.packageJson = packageJson; } return indexRecord; }); return { version: INSTALLED_PLUGIN_INDEX_VERSION, warning: INSTALLED_PLUGIN_INDEX_WARNING, hostContractVersion: resolveCompatibilityHostVersion(env), compatRegistryVersion: resolveCompatRegistryVersion(), migrationVersion: INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, policyHash: resolveInstalledPluginIndexPolicyHash(params.config), generatedAtMs, ...(params.refreshReason ? { refreshReason: params.refreshReason } : {}), installRecords, 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 }); } export function listInstalledPluginRecords( index: InstalledPluginIndex, ): readonly InstalledPluginIndexRecord[] { return index.plugins; } export function listEnabledInstalledPluginRecords( index: InstalledPluginIndex, config?: OpenClawConfig, ): readonly InstalledPluginIndexRecord[] { if (!config) { return index.plugins.filter((plugin) => plugin.enabled); } const normalizedConfig = normalizePluginsConfig(config?.plugins); return index.plugins.filter( (plugin) => resolveEffectiveEnableState({ id: plugin.pluginId, origin: plugin.origin, config: normalizedConfig, rootConfig: config, enabledByDefault: plugin.enabledByDefault, }).enabled, ); } export function getInstalledPluginRecord( index: InstalledPluginIndex, pluginId: string, ): InstalledPluginIndexRecord | undefined { return index.plugins.find((plugin) => plugin.pluginId === pluginId); } export function isInstalledPluginEnabled( index: InstalledPluginIndex, pluginId: string, config?: OpenClawConfig, ): boolean { const record = getInstalledPluginRecord(index, pluginId); if (!record) { return false; } if (!config) { return record.enabled; } const normalizedConfig = normalizePluginsConfig(config?.plugins); return resolveEffectiveEnableState({ id: record.pluginId, origin: record.origin, config: normalizedConfig, rootConfig: config, enabledByDefault: record.enabledByDefault, }).enabled; } 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"); } if (previous.migrationVersion !== current.migrationVersion) { reasons.add("migration"); } if (previous.policyHash !== current.policyHash) { reasons.add("policy-changed"); } if (hashJson(previous.installRecords ?? {}) !== hashJson(current.installRecords ?? {})) { reasons.add("source-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 || previousPlugin.installRecordHash !== currentPlugin.installRecordHash ) { reasons.add("source-changed"); } if (previousPlugin.enabled !== currentPlugin.enabled) { reasons.add("policy-changed"); } if (previousPlugin.manifestHash !== currentPlugin.manifestHash) { reasons.add("stale-manifest"); } if ( previousPlugin.packageVersion !== currentPlugin.packageVersion || previousPlugin.packageJson?.path !== currentPlugin.packageJson?.path || previousPlugin.packageJson?.hash !== currentPlugin.packageJson?.hash ) { reasons.add("stale-package"); } } for (const pluginId of currentByPluginId.keys()) { if (!previousByPluginId.has(pluginId)) { const currentPlugin = currentByPluginId.get(pluginId); if (currentPlugin?.enabled === false) { continue; } reasons.add("source-changed"); } } return Array.from(reasons).toSorted((left, right) => left.localeCompare(right)); }