Files
openclaw/src/plugins/plugin-registry-snapshot.ts
T
Leonidas Lux 0f01930c8e fix(plugins): discover config load.paths plugins when index has entries (#99196)
* fix(plugins): discover config load.paths plugins when index has entries

Installing a managed npm plugin populates the persisted plugin index.
Once the index has entries (index.plugins.length > 0),
loadPluginManifestRegistryForInstalledIndex is used instead of the
full discovery path. This path only processes plugins that already
exist in the index — if workspace plugins from config plugins.load.paths
are missing from the index, they are silently dropped from the manifest
registry, producing 'plugin not found (stale config entry ignored)'
warnings on gateway startup and in CLI commands.

Fix: after converting index records to plugin candidates, also
discover plugins from config plugins.load.paths and merge any
candidates that are not already represented in the index-based
list. This ensures config-origin workspace plugins are always
included in the manifest registry regardless of index completeness.

Fixes #99185

* fix(plugins): address clawsweeper review — config-only scope, pluginId scoping, regression tests

- P1: Limit extra discovery scope to config-origin candidates only
  (filter by origin === 'config') instead of full discoverOpenClawPlugins
- P2: Preserve pluginId scoping for load-path candidates
  (filter extra candidates by pluginIdSet when present)
- Add 3 regression tests: load.paths discovery, empty-load no-op,
  pluginId scoping preservation
- Add L2 real behavior proof with direct function call evidence

🦞 diamond lobster: L2 evidence (real function call + objects)

Ref. https://github.com/openclaw/openclaw/pull/99196

* fix(plugins): replace discoverOpenClawPlugins with config-only discoverFromConfigPaths

P1: The installed-index fallback previously called discoverOpenClawPlugins,
which scans bundled/global/workspace roots and returns their diagnostics.
This allowed unrelated shared-root diagnostics to leak into the
installed-index config validation surface.

Replace it with discoverFromConfigPaths — a new helper that scans only
the user-configured plugins.load.paths entries via discoverFromPath
directly, producing zero bundled/global candidates or diagnostics.

P2: pluginId scoping is preserved for load-path candidates.

Added discoverFromConfigPaths to src/plugins/discovery.ts to keep the
config-only discovery path reusable and explicit.

🦞 diamond lobster: L2 evidence (terminal output from real dev build)

Ref. https://github.com/openclaw/openclaw/pull/99196

* fix(plugins): preserve bundled-load-path alias filtering in discoverFromConfigPaths

P2: The new discoverFromConfigPaths helper (added in Round 1 to fix the
P1 full-discovery issue) bypassed normal discovery's bundled-load-path
alias guard. If a user had a bundled plugin directory in their
plugins.load.paths, the installed-index fallback would treat it as a
valid config-origin candidate and potentially override/duplicate bundled
plugins.

Add resolvePackagedBundledLoadPathAlias check in discoverFromConfigPaths
before calling discoverFromPath, so bundled paths are skipped with a
warning diagnostic matching normal discovery behavior.

Add P2 regression test verifying bundled plugin load paths are ignored
on the installed-index path while real workspace load paths still work.

Ref. https://github.com/openclaw/openclaw/pull/99196

* fix(plugins): forward load-path diagnostics unconditionally in installed-index path

Previously, diagnostics from discoverFromConfigPaths were only forwarded
when at least one extra candidate survived filtering. Configs whose
plugins.load.paths contained only bundled aliases, missing paths, duplicates
already in the index, or scoped-out entries silently lost the normal
discovery warning or doctor hint.

Now extraDiagnostics is assigned outside the extraCandidates.length > 0
guard, so every config load-path diagnostic reaches the caller regardless
of whether a new candidate is merged.

Adds a focused regression test verifying that a bundled-only load path
still surfaces the expected alias-guard warning diagnostic.

🦞 diamond lobster: P2 fix, no new L2 evidence needed (regression test
covers the diagnostic-forwarding contract directly)

Ref. https://github.com/openclaw/openclaw/pull/99196

* fix(plugins): preserve requiresPlugins diagnostics for load paths in installed-index path

- [P2] Export addMissingRequiredPluginDiagnostics from discovery.ts for reuse
- [P2] Call addMissingRequiredPluginDiagnostics on combined index + load-path candidates before passing to loadPluginManifestRegistry
- [P2] Add regression tests: warns when load-path plugin requires missing plugin, does not false-warn when required plugin is already in index

🦞 diamond lobster: L2 + test coverage

Ref. https://github.com/openclaw/openclaw/pull/99196

* fix(plugins): add missing discovery.js mock exports for addMissingRequiredPluginDiagnostics

plugin-install.test.ts mocks discovery.js but the mock didn't include the
newly exported addMissingRequiredPluginDiagnostics, causing CI failures in
tests that exercise loadPluginManifestRegistryForInstalledIndex.

Also add discoverFromConfigPaths to the mock (already imported but was
only latent because the mock's code path wasn't triggered with load paths).

Ref. https://github.com/openclaw/openclaw/pull/99196

* fix(plugins): rebuild stale configured plugin indexes

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-07-06 02:01:00 -07:00

687 lines
24 KiB
TypeScript

// Builds stable snapshots of plugin registry contributions.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveUserPath } from "../utils.js";
import { resolveCompatibilityHostVersion } from "../version.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 { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js";
import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js";
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
import {
inspectPersistedInstalledPluginIndex,
readPersistedInstalledPluginIndexSync,
refreshPersistedInstalledPluginIndex,
type InstalledPluginIndexStoreInspection,
type InstalledPluginIndexStoreOptions,
} from "./installed-plugin-index-store.js";
import {
getInstalledPluginRecord,
extractPluginInstallRecordsFromInstalledPluginIndex,
hasMissingConfigPathActivationMetadata,
isInstalledPluginEnabled,
listInstalledPluginRecords,
loadInstalledPluginIndexWithDiscovery,
resolveInstalledPluginIndexPolicyHash,
type InstalledPluginIndex,
type InstalledPluginIndexRecord,
type LoadInstalledPluginIndexParams,
type RefreshInstalledPluginIndexParams,
} from "./installed-plugin-index.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
import { fileFingerprint } from "./plugin-snapshot-fingerprint.js";
import { resolvePluginCacheInputs } from "./roots.js";
export type PluginRegistrySnapshot = InstalledPluginIndex;
export type PluginRegistryRecord = InstalledPluginIndexRecord;
export type PluginRegistryInspection = InstalledPluginIndexStoreInspection;
export type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
export type PluginRegistrySnapshotDiagnosticCode =
| "persisted-registry-disabled"
| "persisted-registry-missing"
| "persisted-registry-stale-policy"
| "persisted-registry-stale-source";
export type PluginRegistrySnapshotDiagnostic = {
level: "info" | "warn";
code: PluginRegistrySnapshotDiagnosticCode;
message: string;
};
export type PluginRegistrySnapshotResult = {
snapshot: PluginRegistrySnapshot;
source: PluginRegistrySnapshotSource;
diagnostics: readonly PluginRegistrySnapshotDiagnostic[];
discovery?: PluginDiscoveryResult;
};
export const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY";
const MAX_PLUGIN_REGISTRY_SNAPSHOT_MEMOS = 8;
const REGISTRY_SNAPSHOT_MEMO_ENV_KEYS = [
"APPDATA",
"HOME",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_COMPATIBILITY_HOST_VERSION",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS",
DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV,
"OPENCLAW_HOME",
"OPENCLAW_NIX_MODE",
"OPENCLAW_STATE_DIR",
"USERPROFILE",
"XDG_CONFIG_HOME",
] as const;
type PluginRegistrySnapshotMemo = {
key: string;
result: PluginRegistrySnapshotResult;
};
let pluginRegistrySnapshotMemos: PluginRegistrySnapshotMemo[] = [];
function clearLoadPluginRegistrySnapshotMemo(): void {
pluginRegistrySnapshotMemos = [];
}
registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginRegistrySnapshotMemo);
function formatDeprecatedPersistedRegistryDisableWarning(): string {
return `${DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV} is a deprecated break-glass compatibility switch; use \`openclaw plugins registry --refresh\` or \`openclaw doctor --fix\` to repair registry state.`;
}
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams &
InstalledPluginIndexStoreOptions & {
index?: PluginRegistrySnapshot;
preferPersisted?: boolean;
};
export type GetPluginRecordParams = LoadPluginRegistryParams & {
pluginId: string;
};
function hasEnvFlag(env: NodeJS.ProcessEnv, name: string): boolean {
const value = env[name]?.trim().toLowerCase();
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
}
function pickRegistrySnapshotMemoEnv(env: NodeJS.ProcessEnv): Record<string, string> {
return Object.fromEntries(
REGISTRY_SNAPSHOT_MEMO_ENV_KEYS.flatMap((key) => {
const value = env[key];
return value === undefined ? [] : [[key, value]];
}),
);
}
function canMemoizePluginRegistrySnapshot(params: LoadPluginRegistryParams): boolean {
return (
params.index === undefined &&
params.candidates === undefined &&
params.diagnostics === undefined &&
params.discovery === undefined &&
params.installRecords === undefined &&
params.now === undefined &&
params.filePath === undefined &&
params.pluginIndexFilePath === undefined
);
}
function resolvePluginRegistrySnapshotMemoKey(
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
): string | undefined {
if (!canMemoizePluginRegistrySnapshot(params)) {
return undefined;
}
const persistedReadsEnabled =
params.preferPersisted !== false && !hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV);
const persistedRegistryFingerprint = persistedReadsEnabled
? hashJson(
readPersistedInstalledPluginIndexSync({
env,
...(params.stateDir ? { stateDir: params.stateDir } : {}),
}),
)
: "disabled";
return hashJson({
config: params.config ?? null,
cwd: process.cwd(),
env: pickRegistrySnapshotMemoEnv(env),
hostContractVersion: resolveCompatibilityHostVersion(env),
preferPersisted: params.preferPersisted ?? null,
// Plugin manifests are process-stable inside the Gateway, while the persisted
// registry envelope can change through explicit refresh/install flows.
registry: persistedRegistryFingerprint,
pluginRoots: fingerprintPluginSourceRoots(params, env),
stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null,
workspaceDir: params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : null,
});
}
function fingerprintPluginSourceRoots(
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
): unknown {
const workspaceDir = params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : undefined;
const cacheInputs = resolvePluginCacheInputs({
workspaceDir,
loadPaths: normalizePluginsConfig(params.config?.plugins).loadPaths,
env,
});
return {
global: sourceRootFingerprint(cacheInputs.roots.global),
loadPaths: cacheInputs.loadPaths.map((entry) => sourceRootFingerprint(entry)),
stock: cacheInputs.roots.stock ? sourceRootFingerprint(cacheInputs.roots.stock) : null,
workspace: cacheInputs.roots.workspace
? sourceRootFingerprint(cacheInputs.roots.workspace)
: null,
};
}
function sourceRootFingerprint(rootPath: string): unknown {
return {
root: fileFingerprint(rootPath),
// Directory mtimes can be too coarse on some Linux filesystems. Include only
// immediate child names/kinds so same-tick plugin installs invalidate the
// process memo without rereading manifests on hot registry lookups.
children: directoryChildFingerprint(rootPath),
};
}
function directoryChildFingerprint(directoryPath: string): unknown {
try {
return fs
.readdirSync(directoryPath, { withFileTypes: true })
.map((entry) => [entry.name, entry.isDirectory() ? "dir" : entry.isFile() ? "file" : "other"])
.toSorted(([left], [right]) => left.localeCompare(right));
} catch {
return "unreadable";
}
}
function findPluginRegistrySnapshotMemo(
key: string | undefined,
): PluginRegistrySnapshotResult | undefined {
if (!key) {
return undefined;
}
const index = pluginRegistrySnapshotMemos.findIndex((memo) => memo.key === key);
if (index === -1) {
return undefined;
}
const [memo] = pluginRegistrySnapshotMemos.splice(index, 1);
if (!memo) {
return undefined;
}
pluginRegistrySnapshotMemos.unshift(memo);
return memo.result;
}
function rememberPluginRegistrySnapshotMemo(
key: string | undefined,
result: PluginRegistrySnapshotResult,
): PluginRegistrySnapshotResult {
if (!key) {
return result;
}
pluginRegistrySnapshotMemos = [
{ key, result },
...pluginRegistrySnapshotMemos.filter((memo) => memo.key !== key),
].slice(0, MAX_PLUGIN_REGISTRY_SNAPSHOT_MEMOS);
return result;
}
function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean {
return (
params.preferPersisted !== false &&
params.stateDir === undefined &&
params.filePath === undefined &&
params.pluginIndexFilePath === undefined &&
params.installRecords === undefined &&
params.candidates === undefined &&
params.diagnostics === undefined &&
params.now === undefined
);
}
function loadCurrentPluginRegistrySnapshotResult(
params: LoadPluginRegistryParams,
): PluginRegistrySnapshotResult | undefined {
if (!canReuseCurrentPluginMetadataSnapshot(params)) {
return undefined;
}
const env = params.env ?? process.env;
const current = getCurrentPluginMetadataSnapshot({
config: params.config,
env,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
...(params.workspaceDir === undefined ? { allowWorkspaceScopedSnapshot: true } : {}),
});
if (!current || current.registryDiagnostics.length > 0) {
return undefined;
}
return {
snapshot: current.index,
source: "provided",
diagnostics: current.registryDiagnostics,
};
}
function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean {
return index.plugins.some((plugin) => {
if (!plugin.enabled) {
return false;
}
return (
!fs.existsSync(plugin.rootDir) ||
(!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) ||
(plugin.source ? !fs.existsSync(plugin.source) : false) ||
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)
);
});
}
function hasMismatchedPersistedConfigPathPlugins(
index: InstalledPluginIndex,
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
): boolean {
const loadPaths = normalizePluginsConfig(params.config?.plugins).loadPaths;
const discovery = discoverConfiguredPluginLoadPaths({
loadPaths,
workspaceDir: params.workspaceDir,
env,
});
const configuredRoots = loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index),
}).plugins.map((plugin) => resolveComparablePath(plugin.rootDir));
const persistedRoots = index.plugins
.filter((plugin) => plugin.origin === "config")
.map((plugin) => resolveComparablePath(plugin.rootDir));
if (configuredRoots.length !== persistedRoots.length) {
return true;
}
return configuredRoots.some((rootDir, position) => rootDir !== persistedRoots[position]);
}
function resolveComparablePath(filePath: string): string {
try {
return fs.realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
function isRelativePathInsideOrEqual(relativePath: string): boolean {
return (
relativePath === "" ||
(relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
);
}
function isPathInsideOrEqual(childPath: string, parentPath: string): boolean {
const relative = path.relative(
resolveComparablePath(parentPath),
resolveComparablePath(childPath),
);
return isRelativePathInsideOrEqual(relative);
}
function hasMismatchedPersistedBundledPluginRoot(
index: InstalledPluginIndex,
env: NodeJS.ProcessEnv,
): boolean {
const bundledPluginsDir = resolveBundledPluginsDir(env);
if (!bundledPluginsDir) {
return false;
}
let sourceOverlayDirs: string[] | undefined;
return index.plugins.some((plugin) => {
if (plugin.origin !== "bundled" || isPathInsideOrEqual(plugin.rootDir, bundledPluginsDir)) {
return false;
}
sourceOverlayDirs ??= listBundledSourceOverlayDirs({
bundledRoot: bundledPluginsDir,
env,
});
return !isAllowedPersistedBundledPluginRoot(
plugin.rootDir,
bundledPluginsDir,
sourceOverlayDirs,
);
});
}
function isAllowedPersistedBundledPluginRoot(
pluginRootDir: string,
bundledPluginsDir: string,
sourceOverlayDirs: readonly string[],
): boolean {
if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir)) {
return true;
}
if (sourceOverlayDirs.some((overlayDir) => isPathInsideOrEqual(pluginRootDir, overlayDir))) {
return true;
}
const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir);
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
return false;
}
const relativePluginRoot = path.relative(
resolveComparablePath(legacyRoot),
resolveComparablePath(pluginRootDir),
);
if (!isRelativePathInsideOrEqual(relativePluginRoot)) {
return false;
}
// Discovery prefers a built plugin whenever the same child exists in the
// packaged root. Keep source-only bundled plugins, but invalidate stale
// source records once their built peer appears.
return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot));
}
function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean {
const packageRoot = path.dirname(extensionsDir);
return (
fs.existsSync(extensionsDir) &&
fs.existsSync(path.join(packageRoot, ".git")) &&
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
fs.existsSync(path.join(packageRoot, "src"))
);
}
function hashExistingFile(filePath: string): string | null {
try {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
} catch {
return null;
}
}
function resolveRecordPackageJsonPath(plugin: InstalledPluginIndexRecord): string | null {
const packageJsonPath = plugin.packageJson?.path;
if (!packageJsonPath) {
return null;
}
const rootDir = plugin.rootDir || path.dirname(plugin.manifestPath);
const resolved = path.resolve(rootDir, packageJsonPath);
const relative = path.relative(rootDir, resolved);
if (!isRelativePathInsideOrEqual(relative)) {
return null;
}
const realRelative = path.relative(
resolveComparablePath(rootDir),
resolveComparablePath(resolved),
);
return isRelativePathInsideOrEqual(realRelative) ? resolved : null;
}
function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean {
return index.diagnostics.some((diag) => {
const source = diag.source;
return (
typeof diag.pluginId === "string" &&
diag.pluginId.trim().length > 0 &&
typeof source === "string" &&
path.isAbsolute(source) &&
!fs.existsSync(source)
);
});
}
function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
return index.plugins.some((plugin) => {
if (!hasOptionalMissingPluginManifestFile(plugin)) {
const manifestSignatureMatches = fileSignatureMatches(
plugin.manifestPath,
plugin.manifestFile,
);
if (manifestSignatureMatches !== true) {
const manifestHash = hashExistingFile(plugin.manifestPath);
if (manifestHash && manifestHash !== plugin.manifestHash) {
return true;
}
}
}
const packageJsonPath = resolveRecordPackageJsonPath(plugin);
if (!plugin.packageJson?.hash) {
return false;
}
if (!packageJsonPath) {
return true;
}
const packageJsonSignatureMatches = fileSignatureMatches(
packageJsonPath,
plugin.packageJson.fileSignature,
);
if (packageJsonSignatureMatches === true && plugin.origin === "bundled") {
return false;
}
if (packageJsonSignatureMatches === false) {
return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash;
}
// Fast same-size rewrites can preserve observable stat fields on some filesystems.
const packageJsonHash = hashExistingFile(packageJsonPath);
return packageJsonHash !== plugin.packageJson.hash;
});
}
function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) {
return loadInstalledPluginIndexInstallRecordsSync({
env,
...(params.stateDir ? { stateDir: params.stateDir } : {}),
...(params.filePath
? { filePath: params.filePath }
: params.pluginIndexFilePath
? { filePath: params.pluginIndexFilePath }
: {}),
});
}
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
index: InstalledPluginIndex,
installRecords: ReturnType<typeof loadInstalledPluginIndexInstallRecordsSync>,
env: NodeJS.ProcessEnv,
): boolean {
const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index);
const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
return Object.entries(installRecords).some(([pluginId, record]) => {
if (persistedRecords[pluginId] && persistedPluginIds.has(pluginId)) {
return false;
}
const installPaths = [record.installPath, record.sourcePath].filter(
(candidate): candidate is string =>
typeof candidate === "string" && candidate.trim().length > 0,
);
if (installPaths.length === 0) {
return true;
}
return installPaths.some((installPath) => fs.existsSync(resolveUserPath(installPath, env)));
});
}
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 memoKey = resolvePluginRegistrySnapshotMemoKey(params, env);
const memo = findPluginRegistrySnapshotMemo(memoKey);
if (memo) {
return memo;
}
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
const disabledByCaller = params.preferPersisted === false;
const disabledByEnv = hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV);
const persistedReadsEnabled = !disabledByCaller && !disabledByEnv;
const persistedInstallRecordReadsEnabled = persistedReadsEnabled;
let persistedIndex: InstalledPluginIndex | null;
if (persistedInstallRecordReadsEnabled) {
persistedIndex = readPersistedInstalledPluginIndexSync(params);
if (persistedReadsEnabled && persistedIndex) {
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 (hasMissingPersistedPluginSource(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasStalePersistedPluginDiagnostics(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasMissingConfigPathActivationMetadata(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasStalePersistedPluginMetadata(persistedIndex)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (
hasRecoveredInstallRecordsMissingFromPersistedIndex(
persistedIndex,
loadSnapshotInstallRecords(params, env),
env,
)
) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else {
const persistedResult: PluginRegistrySnapshotResult = {
snapshot: persistedIndex,
source: "persisted",
diagnostics,
};
return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult);
}
} else if (persistedReadsEnabled) {
diagnostics.push({
level: "info",
code: "persisted-registry-missing",
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
});
}
} else {
diagnostics.push({
level: "warn",
code: "persisted-registry-disabled",
message: disabledByEnv
? `${formatDeprecatedPersistedRegistryDisableWarning()} Using legacy derived plugin index.`
: "Persisted plugin registry reads are disabled by the caller; using derived plugin index.",
});
}
const derived = loadInstalledPluginIndexWithDiscovery({
...params,
installRecords: persistedInstallRecordReadsEnabled
? params.installRecords
: (params.installRecords ?? {}),
});
return rememberPluginRegistrySnapshotMemo(memoKey, {
snapshot: derived.index,
source: "derived",
diagnostics,
discovery: derived.discovery,
});
}
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {
return loadPluginRegistrySnapshotWithMetadata(params).snapshot;
}
export function loadPluginRegistrySnapshot(
params: LoadPluginRegistryParams = {},
): PluginRegistrySnapshot {
return resolveSnapshot(params);
}
export function listPluginRecords(
params: LoadPluginRegistryParams = {},
): readonly PluginRegistryRecord[] {
return listInstalledPluginRecords(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 function inspectPluginRegistry(
params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {},
): Promise<PluginRegistryInspection> {
return inspectPersistedInstalledPluginIndex(params);
}
export function refreshPluginRegistry(
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
): Promise<PluginRegistrySnapshot> {
return refreshPersistedInstalledPluginIndex(params);
}