mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
685 lines
23 KiB
TypeScript
685 lines
23 KiB
TypeScript
// Builds stable snapshots of plugin registry contributions.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import { tryReadJsonSync } from "../infra/json-files.js";
|
|
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
|
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
|
|
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
|
|
import { normalizePluginsConfig } from "./config-state.js";
|
|
import {
|
|
appendPluginControlPlaneWorkspaceDiagnostic,
|
|
resolvePluginControlPlaneWorkspace,
|
|
} from "./control-plane-workspace.js";
|
|
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
|
|
import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js";
|
|
import { resolvePluginDoctorContractArtifactPath } from "./doctor-contract-artifact.js";
|
|
import { safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js";
|
|
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
|
|
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
|
|
import {
|
|
readPersistedInstalledPluginIndexSync,
|
|
refreshPersistedInstalledPluginIndex,
|
|
type InstalledPluginIndexStoreOptions,
|
|
} from "./installed-plugin-index-store.js";
|
|
import {
|
|
diffInstalledPluginIndexInvalidationReasons,
|
|
extractPluginInstallRecordsFromInstalledPluginIndex,
|
|
getInstalledPluginRecord,
|
|
hasInstalledPluginIndexWorkspaceScopeMismatch,
|
|
hasMissingConfigPathActivationMetadata,
|
|
isInstalledPluginEnabled,
|
|
loadInstalledPluginIndexWithDiscovery,
|
|
resolveInstalledPluginIndexPolicyHash,
|
|
type InstalledPluginIndex,
|
|
type InstalledPluginIndexRecord,
|
|
type LoadInstalledPluginIndexParams,
|
|
type RefreshInstalledPluginIndexParams,
|
|
} from "./installed-plugin-index.js";
|
|
import { hasMissingInstalledPluginOwnerMetadata } from "./installed-plugin-package-ownership.js";
|
|
import {
|
|
loadPluginManifestRegistryCore,
|
|
type PluginManifestRegistry,
|
|
} from "./manifest-registry.js";
|
|
import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js";
|
|
import { isPathInside, safeRealpathSync } from "./path-safety.js";
|
|
import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
|
|
import { resolvePluginSourceRoots } from "./roots.js";
|
|
|
|
function resolvePluginRegistryContent(
|
|
index: InstalledPluginIndex,
|
|
comparePackageJsonPath: boolean,
|
|
excludedPlugins?: ReadonlyMap<string, string>,
|
|
): unknown {
|
|
const {
|
|
generatedAtMs: _generatedAtMs,
|
|
refreshReason: _refreshReason,
|
|
warning: _warning,
|
|
...content
|
|
} = index;
|
|
const excludedRoots = [...(excludedPlugins?.values() ?? [])].map((root) => path.resolve(root));
|
|
const exclusionPathCache = new Map<string, string>();
|
|
return {
|
|
...content,
|
|
diagnostics: excludedPlugins
|
|
? content.diagnostics.filter(
|
|
(diagnostic) =>
|
|
!(
|
|
(diagnostic.pluginId && excludedPlugins.has(diagnostic.pluginId)) ||
|
|
(diagnostic.source &&
|
|
excludedRoots.some((root) =>
|
|
isContainedPluginPath(root, diagnostic.source!, exclusionPathCache),
|
|
))
|
|
),
|
|
)
|
|
: content.diagnostics,
|
|
installRecords: excludedPlugins
|
|
? Object.fromEntries(
|
|
Object.entries(content.installRecords).filter(
|
|
([pluginId]) => !excludedPlugins.has(pluginId),
|
|
),
|
|
)
|
|
: content.installRecords,
|
|
plugins: content.plugins
|
|
.filter((plugin) => !excludedPlugins?.has(plugin.pluginId))
|
|
.map((plugin) => {
|
|
const {
|
|
doctorContractFile: _doctorContractFile,
|
|
manifestFile: _manifestFile,
|
|
packageBuild,
|
|
packageJson,
|
|
...record
|
|
} = plugin;
|
|
// Compare the durable package-build contract. The store intentionally drops
|
|
// build-only metadata that runtime selection does not consume.
|
|
const stableRecord = Object.assign(
|
|
record,
|
|
packageBuild === undefined
|
|
? {}
|
|
: {
|
|
packageBuild:
|
|
packageBuild.bundledDist === undefined
|
|
? {}
|
|
: { bundledDist: packageBuild.bundledDist },
|
|
},
|
|
);
|
|
if (!packageJson) {
|
|
return stableRecord;
|
|
}
|
|
if (!comparePackageJsonPath) {
|
|
return stableRecord;
|
|
}
|
|
const {
|
|
fileSignature: _fileSignature,
|
|
path: packageJsonPath,
|
|
...stablePackageJson
|
|
} = packageJson;
|
|
return Object.assign(stableRecord, {
|
|
packageJson: Object.assign(stablePackageJson, { path: packageJsonPath }),
|
|
});
|
|
}),
|
|
};
|
|
}
|
|
|
|
export type PluginRegistrySnapshot = InstalledPluginIndex;
|
|
export type PluginRegistryRecord = InstalledPluginIndexRecord;
|
|
export type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
|
|
type PluginRegistrySnapshotDiagnosticCode =
|
|
| "persisted-registry-missing"
|
|
| "persisted-registry-stale-policy"
|
|
| "persisted-registry-stale-source";
|
|
|
|
export type PluginRegistrySnapshotDiagnostic = {
|
|
level: "info" | "warn";
|
|
code: PluginRegistrySnapshotDiagnosticCode;
|
|
message: string;
|
|
};
|
|
|
|
type PluginRegistrySnapshotResult = {
|
|
snapshot: PluginRegistrySnapshot;
|
|
source: PluginRegistrySnapshotSource;
|
|
diagnostics: readonly PluginRegistrySnapshotDiagnostic[];
|
|
discovery?: PluginDiscoveryResult;
|
|
manifestRegistry?: PluginManifestRegistry;
|
|
};
|
|
|
|
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams &
|
|
InstalledPluginIndexStoreOptions & {
|
|
index?: PluginRegistrySnapshot;
|
|
preferPersisted?: boolean;
|
|
allowCurrent?: boolean;
|
|
};
|
|
|
|
type GetPluginRecordParams = LoadPluginRegistryParams & {
|
|
pluginId: string;
|
|
};
|
|
|
|
function resolveControlPlaneRegistryParams<T extends LoadInstalledPluginIndexParams>(params: T): T {
|
|
if (!params.config) {
|
|
return params;
|
|
}
|
|
const workspace = resolvePluginControlPlaneWorkspace({
|
|
config: params.config,
|
|
env: params.env,
|
|
workspaceDir: params.workspaceDir,
|
|
});
|
|
const diagnostics = appendPluginControlPlaneWorkspaceDiagnostic(
|
|
params.diagnostics ?? [],
|
|
workspace,
|
|
);
|
|
return {
|
|
...params,
|
|
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
...(workspace.workspaceDir !== undefined ? { workspaceDir: workspace.workspaceDir } : {}),
|
|
};
|
|
}
|
|
|
|
function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean {
|
|
return (
|
|
params.allowCurrent !== false &&
|
|
params.preferPersisted !== false &&
|
|
params.stateDir === undefined &&
|
|
params.filePath === undefined &&
|
|
params.pluginIndexFilePath === undefined &&
|
|
params.installRecords === undefined &&
|
|
params.candidates === undefined &&
|
|
params.diagnostics === undefined &&
|
|
params.discovery === undefined &&
|
|
params.now === undefined
|
|
);
|
|
}
|
|
|
|
function loadCurrentPluginRegistrySnapshotResult(
|
|
params: LoadPluginRegistryParams,
|
|
): PluginRegistrySnapshotResult | undefined {
|
|
if (!canReuseCurrentPluginMetadataSnapshot(params)) {
|
|
return undefined;
|
|
}
|
|
const current = getCurrentPluginMetadataSnapshot({
|
|
config: params.config,
|
|
env: params.env ?? process.env,
|
|
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
|
|
});
|
|
if (!current) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
snapshot: current.index,
|
|
source:
|
|
current.registrySource ?? (current.registryDiagnostics.length > 0 ? "derived" : "provided"),
|
|
diagnostics: current.registryDiagnostics,
|
|
...(current.discovery ? { discovery: current.discovery } : {}),
|
|
manifestRegistry: current.manifestRegistry,
|
|
};
|
|
}
|
|
|
|
function fileContentMatches(
|
|
filePath: string,
|
|
hash: string,
|
|
signature?: InstalledPluginIndexRecord["manifestFile"],
|
|
trustSignature = true,
|
|
): boolean {
|
|
const current = safeFileSignature(filePath);
|
|
if (!current) {
|
|
return false;
|
|
}
|
|
if (
|
|
trustSignature &&
|
|
signature?.ctimeMs !== undefined &&
|
|
current.size === signature.size &&
|
|
current.mtimeMs === signature.mtimeMs &&
|
|
current.ctimeMs === signature.ctimeMs
|
|
) {
|
|
return true;
|
|
}
|
|
return safeHashFile({ filePath, diagnostics: [], required: false }) === hash;
|
|
}
|
|
|
|
function isContainedPluginPath(
|
|
rootPath: string,
|
|
targetPath: string,
|
|
cache: Map<string, string>,
|
|
): boolean {
|
|
// Project unresolved suffixes from the nearest real ancestor so missing disabled
|
|
// artifacts stay inspectable without accepting symlink or path-alias escapes.
|
|
const resolveProjectedPath = (inputPath: string): string | null => {
|
|
const target = path.resolve(inputPath);
|
|
for (let cursor = target; ; cursor = path.dirname(cursor)) {
|
|
try {
|
|
fs.lstatSync(cursor);
|
|
const realCursor = safeRealpathSync(cursor, cache);
|
|
return realCursor ? path.resolve(realCursor, path.relative(cursor, target)) : null;
|
|
} catch {
|
|
if (cursor === path.dirname(cursor)) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const root = resolveProjectedPath(rootPath);
|
|
const target = resolveProjectedPath(targetPath);
|
|
return Boolean(root && target && isPathInside(root, target));
|
|
}
|
|
|
|
function hasStaleDoctorContractFile(
|
|
plugin: InstalledPluginIndexRecord,
|
|
rootExists: boolean,
|
|
): boolean {
|
|
if (!rootExists && !plugin.enabled) {
|
|
return false;
|
|
}
|
|
const contractPath = resolvePluginDoctorContractArtifactPath(plugin.rootDir);
|
|
return contractPath
|
|
? !plugin.doctorContractHash ||
|
|
!fileContentMatches(contractPath, plugin.doctorContractHash, plugin.doctorContractFile)
|
|
: plugin.doctorContractHash !== undefined || plugin.doctorContractFile !== undefined;
|
|
}
|
|
|
|
function hasStalePersistedPluginFiles(index: InstalledPluginIndex): boolean {
|
|
const realpathCache = new Map<string, string>();
|
|
return index.plugins.some((plugin) => {
|
|
if (!isContainedPluginPath(plugin.rootDir, plugin.rootDir, realpathCache)) {
|
|
return true;
|
|
}
|
|
const rootExists = fs.existsSync(plugin.rootDir);
|
|
if (!rootExists && plugin.enabled) {
|
|
return true;
|
|
}
|
|
for (const artifactPath of [plugin.source, plugin.setupSource, plugin.manifestPath]) {
|
|
if (artifactPath && !isContainedPluginPath(plugin.rootDir, artifactPath, realpathCache)) {
|
|
return true;
|
|
}
|
|
}
|
|
if (
|
|
plugin.enabled &&
|
|
((plugin.source ? !fs.existsSync(plugin.source) : false) ||
|
|
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false))
|
|
) {
|
|
return true;
|
|
}
|
|
if (!hasOptionalMissingPluginManifestFile(plugin)) {
|
|
if (!fs.existsSync(plugin.manifestPath)) {
|
|
if (plugin.enabled) {
|
|
return true;
|
|
}
|
|
} else if (
|
|
!fileContentMatches(plugin.manifestPath, plugin.manifestHash, plugin.manifestFile)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
if (hasStaleDoctorContractFile(plugin, rootExists)) {
|
|
return true;
|
|
}
|
|
if (!plugin.packageJson) {
|
|
return false;
|
|
}
|
|
const packageJsonPath = path.resolve(plugin.rootDir, plugin.packageJson.path);
|
|
if (!isContainedPluginPath(plugin.rootDir, packageJsonPath, realpathCache)) {
|
|
return true;
|
|
}
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
return plugin.enabled;
|
|
}
|
|
if (!isRealPathInside(plugin.rootDir, packageJsonPath, realpathCache)) {
|
|
return true;
|
|
}
|
|
return !fileContentMatches(
|
|
packageJsonPath,
|
|
plugin.packageJson.hash,
|
|
plugin.packageJson.fileSignature,
|
|
plugin.origin === "bundled",
|
|
);
|
|
});
|
|
}
|
|
|
|
function isRealPathInside(
|
|
parentPath: string,
|
|
childPath: string,
|
|
cache: Map<string, string>,
|
|
): boolean {
|
|
const parent = safeRealpathSync(parentPath, cache);
|
|
const child = safeRealpathSync(childPath, cache);
|
|
return Boolean(parent && child && isPathInside(parent, child));
|
|
}
|
|
|
|
function hasMismatchedPersistedBundledRoot(
|
|
index: InstalledPluginIndex,
|
|
env: NodeJS.ProcessEnv,
|
|
): boolean {
|
|
const bundledRoot = resolveBundledPluginsDir(env);
|
|
if (!bundledRoot) {
|
|
return false;
|
|
}
|
|
const realpathCache = new Map<string, string>();
|
|
const overlays = listBundledSourceOverlayDirs({ bundledRoot, env });
|
|
const legacyRoot = buildLegacyBundledRootPath(bundledRoot);
|
|
const sourceCheckout =
|
|
legacyRoot &&
|
|
fs.existsSync(path.join(path.dirname(legacyRoot), ".git")) &&
|
|
fs.existsSync(path.join(path.dirname(legacyRoot), "pnpm-workspace.yaml")) &&
|
|
fs.existsSync(path.join(path.dirname(legacyRoot), "src"));
|
|
return index.plugins.some((plugin) => {
|
|
if (plugin.origin !== "bundled") {
|
|
return false;
|
|
}
|
|
if (!plugin.enabled && !fs.existsSync(plugin.rootDir)) {
|
|
const allowedRoots = [bundledRoot, ...overlays, ...(legacyRoot ? [legacyRoot] : [])];
|
|
return !allowedRoots.some((root) =>
|
|
isContainedPluginPath(root, plugin.rootDir, realpathCache),
|
|
);
|
|
}
|
|
if (isRealPathInside(bundledRoot, plugin.rootDir, realpathCache)) {
|
|
if (!sourceCheckout) {
|
|
return false;
|
|
}
|
|
const resolvedBundledRoot = safeRealpathSync(bundledRoot, realpathCache) ?? bundledRoot;
|
|
const resolvedPluginRoot = safeRealpathSync(plugin.rootDir, realpathCache) ?? plugin.rootDir;
|
|
const sourcePackage = tryReadJsonSync<PackageManifest>(
|
|
path.join(
|
|
legacyRoot,
|
|
path.relative(resolvedBundledRoot, resolvedPluginRoot),
|
|
"package.json",
|
|
),
|
|
);
|
|
return getPackageManifestMetadata(sourcePackage ?? undefined)?.build?.bundledDist === false;
|
|
}
|
|
return (
|
|
!overlays.some((root) => isRealPathInside(root, plugin.rootDir, realpathCache)) &&
|
|
!(
|
|
plugin.packageBuild?.bundledDist === false &&
|
|
legacyRoot &&
|
|
isRealPathInside(legacyRoot, plugin.rootDir, realpathCache)
|
|
)
|
|
);
|
|
});
|
|
}
|
|
|
|
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
|
index: InstalledPluginIndex,
|
|
params: LoadPluginRegistryParams,
|
|
env: NodeJS.ProcessEnv,
|
|
): boolean {
|
|
const installRecords = loadInstalledPluginIndexInstallRecordsSync({
|
|
env,
|
|
...(params.stateDir ? { stateDir: params.stateDir } : {}),
|
|
...(params.filePath
|
|
? { filePath: params.filePath }
|
|
: params.pluginIndexFilePath
|
|
? { filePath: params.pluginIndexFilePath }
|
|
: {}),
|
|
});
|
|
// A durable owner can outlive removed package bytes. Lifecycle mutations fail
|
|
// closed without child rows; registry recovery only needs to detect records
|
|
// that are absent from the persisted top-level ledger.
|
|
return Object.keys(installRecords).some((pluginId) => !index.installRecords?.[pluginId]);
|
|
}
|
|
|
|
function requiresDerivedRegistryValidation(
|
|
index: InstalledPluginIndex,
|
|
params: LoadPluginRegistryParams,
|
|
env: NodeJS.ProcessEnv,
|
|
hasStalePluginFiles: () => boolean,
|
|
): boolean {
|
|
return (
|
|
hasInstalledPluginIndexWorkspaceScopeMismatch(index, params.workspaceDir) ||
|
|
params.candidates !== undefined ||
|
|
params.discovery !== undefined ||
|
|
params.diagnostics !== undefined ||
|
|
params.installRecords !== undefined ||
|
|
normalizePluginsConfig(params.config?.plugins).loadPaths.length > 0 ||
|
|
hasMissingConfigPathActivationMetadata(index) ||
|
|
hasMissingInstalledPluginOwnerMetadata(index, env) ||
|
|
index.diagnostics.some(({ pluginId, source }) =>
|
|
Boolean(pluginId && source && path.isAbsolute(source) && !fs.existsSync(source)),
|
|
) ||
|
|
hasMismatchedPersistedBundledRoot(index, env) ||
|
|
hasStalePluginFiles() ||
|
|
hasRecoveredInstallRecordsMissingFromPersistedIndex(index, params, env) ||
|
|
hasConfiguredGlobalSourcePluginMissingFromPersistedIndex(params, index, env)
|
|
);
|
|
}
|
|
|
|
function collectConfiguredPluginIds(config: LoadPluginRegistryParams["config"]): Set<string> {
|
|
const plugins = normalizePluginsConfig(config?.plugins);
|
|
const pluginIds = new Set<string>();
|
|
for (const pluginId of Object.keys(plugins.entries)) {
|
|
pluginIds.add(pluginId);
|
|
}
|
|
for (const pluginId of plugins.allow) {
|
|
pluginIds.add(pluginId);
|
|
}
|
|
for (const pluginId of Object.values(plugins.slots)) {
|
|
if (typeof pluginId === "string" && pluginId.trim() && pluginId !== "none") {
|
|
pluginIds.add(pluginId);
|
|
}
|
|
}
|
|
return pluginIds;
|
|
}
|
|
|
|
function hasConfiguredGlobalSourcePluginMissingFromPersistedIndex(
|
|
params: LoadPluginRegistryParams,
|
|
index: InstalledPluginIndex,
|
|
env: NodeJS.ProcessEnv,
|
|
): boolean {
|
|
const configuredPluginIds = collectConfiguredPluginIds(params.config);
|
|
const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
|
|
const missingConfiguredPluginIds = new Set(
|
|
[...configuredPluginIds].filter((pluginId) => !persistedPluginIds.has(pluginId)),
|
|
);
|
|
if (missingConfiguredPluginIds.size === 0) {
|
|
return false;
|
|
}
|
|
const globalExtensionsRoot = resolvePluginSourceRoots({
|
|
workspaceDir: params.workspaceDir,
|
|
env,
|
|
}).global;
|
|
const discovery = discoverConfiguredPluginLoadPaths({
|
|
loadPaths: [globalExtensionsRoot],
|
|
workspaceDir: params.workspaceDir,
|
|
env,
|
|
});
|
|
const registry = loadPluginManifestRegistryCore({
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
env,
|
|
candidates: discovery.candidates,
|
|
diagnostics: discovery.diagnostics,
|
|
installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index),
|
|
});
|
|
return registry.plugins.some((plugin) => missingConfiguredPluginIds.has(plugin.id));
|
|
}
|
|
|
|
export function loadPluginRegistrySnapshotWithMetadata(
|
|
params: LoadPluginRegistryParams = {},
|
|
): PluginRegistrySnapshotResult {
|
|
if (params.index) {
|
|
return {
|
|
snapshot: params.index,
|
|
source: "provided",
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
const current = loadCurrentPluginRegistrySnapshotResult(params);
|
|
if (current) {
|
|
return current;
|
|
}
|
|
|
|
const env = params.env ?? process.env;
|
|
const persistedReadsEnabled = params.preferPersisted !== false;
|
|
if (!persistedReadsEnabled) {
|
|
const derived = loadInstalledPluginIndexWithDiscovery({
|
|
...params,
|
|
installRecords: params.installRecords ?? {},
|
|
});
|
|
return {
|
|
snapshot: derived.index,
|
|
source: "derived",
|
|
diagnostics: [],
|
|
discovery: derived.discovery,
|
|
manifestRegistry: derived.manifestRegistry,
|
|
};
|
|
}
|
|
|
|
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
|
|
const persistedIndex = readPersistedInstalledPluginIndexSync(params);
|
|
let stalePluginFiles: boolean | undefined;
|
|
const hasStalePluginFiles = () =>
|
|
(stalePluginFiles ??= persistedIndex ? hasStalePersistedPluginFiles(persistedIndex) : false);
|
|
if (!persistedIndex) {
|
|
diagnostics.push({
|
|
level: "info",
|
|
code: "persisted-registry-missing",
|
|
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
|
|
});
|
|
} else if (
|
|
params.config &&
|
|
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
|
|
) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-policy",
|
|
message:
|
|
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
} else if (!requiresDerivedRegistryValidation(persistedIndex, params, env, hasStalePluginFiles)) {
|
|
return {
|
|
snapshot: persistedIndex,
|
|
source: "persisted",
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
const derived = loadInstalledPluginIndexWithDiscovery({
|
|
...params,
|
|
...(params.filePath && !params.pluginIndexFilePath
|
|
? { pluginIndexFilePath: params.filePath }
|
|
: {}),
|
|
});
|
|
const comparePackageJsonPath =
|
|
params.candidates !== undefined || params.discovery !== undefined || hasStalePluginFiles();
|
|
const excludedMissingDisabledPlugins = new Map<string, string>();
|
|
if (
|
|
persistedIndex &&
|
|
params.candidates === undefined &&
|
|
params.discovery === undefined &&
|
|
params.installRecords === undefined &&
|
|
!hasStalePluginFiles() &&
|
|
!hasMismatchedPersistedBundledRoot(persistedIndex, env)
|
|
) {
|
|
const derivedPluginIds = new Set(derived.index.plugins.map((plugin) => plugin.pluginId));
|
|
for (const plugin of persistedIndex.plugins) {
|
|
if (!plugin.enabled && !derivedPluginIds.has(plugin.pluginId)) {
|
|
excludedMissingDisabledPlugins.set(plugin.pluginId, plugin.rootDir);
|
|
}
|
|
}
|
|
}
|
|
const contentMatches =
|
|
persistedIndex &&
|
|
diagnostics.length === 0 &&
|
|
isDeepStrictEqual(
|
|
resolvePluginRegistryContent(
|
|
persistedIndex,
|
|
comparePackageJsonPath,
|
|
excludedMissingDisabledPlugins,
|
|
),
|
|
resolvePluginRegistryContent(
|
|
derived.index,
|
|
comparePackageJsonPath,
|
|
excludedMissingDisabledPlugins,
|
|
),
|
|
);
|
|
if (persistedIndex && contentMatches) {
|
|
const packageMetadataMatches = isDeepStrictEqual(
|
|
resolvePluginRegistryContent(persistedIndex, true),
|
|
resolvePluginRegistryContent(derived.index, true),
|
|
);
|
|
return {
|
|
snapshot: persistedIndex,
|
|
source: "persisted",
|
|
diagnostics,
|
|
discovery: derived.discovery,
|
|
...(packageMetadataMatches ? { manifestRegistry: derived.manifestRegistry } : {}),
|
|
};
|
|
} else if (persistedIndex && diagnostics.length === 0) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-source",
|
|
message:
|
|
"Persisted plugin registry no longer matches current plugin discovery or metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
}
|
|
|
|
return {
|
|
snapshot: derived.index,
|
|
source: "derived",
|
|
diagnostics,
|
|
discovery: derived.discovery,
|
|
manifestRegistry: derived.manifestRegistry,
|
|
};
|
|
}
|
|
|
|
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {
|
|
return loadPluginRegistrySnapshotWithMetadata(params).snapshot;
|
|
}
|
|
|
|
export function loadPluginRegistrySnapshot(
|
|
params: LoadPluginRegistryParams = {},
|
|
): PluginRegistrySnapshot {
|
|
return resolveSnapshot(params);
|
|
}
|
|
|
|
export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined {
|
|
return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId);
|
|
}
|
|
|
|
export function isPluginEnabled(params: GetPluginRecordParams): boolean {
|
|
return isInstalledPluginEnabled(resolveSnapshot(params), params.pluginId, params.config);
|
|
}
|
|
|
|
export async function inspectPluginRegistry(
|
|
params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {},
|
|
) {
|
|
const inspectionParams = resolveControlPlaneRegistryParams(params);
|
|
const persisted = readPersistedInstalledPluginIndexSync(inspectionParams);
|
|
// Inspection and runtime selection share one verdict so runtime cannot reject "fresh".
|
|
const result = loadPluginRegistrySnapshotWithMetadata({
|
|
...inspectionParams,
|
|
allowCurrent: false,
|
|
});
|
|
if (!persisted) {
|
|
return {
|
|
state: "missing" as const,
|
|
refreshReasons: ["missing"],
|
|
persisted: null,
|
|
current: result.snapshot,
|
|
};
|
|
}
|
|
const fresh = result.source === "persisted";
|
|
const refreshReasons = fresh
|
|
? []
|
|
: [...diffInstalledPluginIndexInvalidationReasons(persisted, result.snapshot)];
|
|
if (!fresh && refreshReasons.length === 0) {
|
|
refreshReasons.push(
|
|
result.diagnostics.some((diagnostic) => diagnostic.code === "persisted-registry-stale-policy")
|
|
? "policy-changed"
|
|
: "source-changed",
|
|
);
|
|
}
|
|
return {
|
|
state: fresh ? ("fresh" as const) : ("stale" as const),
|
|
refreshReasons,
|
|
persisted,
|
|
current: result.snapshot,
|
|
};
|
|
}
|
|
|
|
export function refreshPluginRegistry(
|
|
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
|
|
): Promise<PluginRegistrySnapshot> {
|
|
if (!params.config) {
|
|
return refreshPersistedInstalledPluginIndex(params);
|
|
}
|
|
return refreshPersistedInstalledPluginIndex(resolveControlPlaneRegistryParams(params));
|
|
}
|