mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(plugins): stop repeated manifest realpath scans (#115783)
Fixes #115524 by keeping path canonicalization scoped to each manifest and persisted registry build. Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
committed by
GitHub
parent
a27e5ee607
commit
05516e7a5f
@@ -1906,6 +1906,30 @@ describe("loadPluginManifestRegistry", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a manifest provider catalog source only once per registry build", () => {
|
||||
const dir = makeTempDir();
|
||||
const providerDiscoverySource = path.join(dir, "provider-discovery.js");
|
||||
writeManifest(dir, {
|
||||
id: "cached-provider",
|
||||
providers: ["cached-provider"],
|
||||
providerCatalogEntry: "./provider-discovery.js",
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
fs.writeFileSync(providerDiscoverySource, "export default {};\n", "utf8");
|
||||
const realpathSpy = vi.spyOn(fs, "realpathSync");
|
||||
|
||||
const registry = loadSingleCandidateRegistry({
|
||||
idHint: "cached-provider",
|
||||
rootDir: dir,
|
||||
origin: "bundled",
|
||||
});
|
||||
|
||||
expect(registry.plugins[0]?.providerDiscoverySource).toBe(providerDiscoverySource);
|
||||
expect(
|
||||
realpathSpy.mock.calls.filter(([filePath]) => filePath === providerDiscoverySource),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores provider catalog entries outside the plugin root", () => {
|
||||
const root = makeTempDir();
|
||||
const pluginDir = path.join(root, "plugin");
|
||||
|
||||
@@ -92,6 +92,7 @@ function isPluginRootPath(params: {
|
||||
rootPath: string;
|
||||
targetPath: string;
|
||||
rootRealPath: string;
|
||||
realpathCache: Map<string, string>;
|
||||
rejectHardlinks?: boolean;
|
||||
targetMustExist?: boolean;
|
||||
}): boolean {
|
||||
@@ -100,7 +101,7 @@ function isPluginRootPath(params: {
|
||||
if (!isPathInside(resolvedRootPath, resolvedTargetPath)) {
|
||||
return false;
|
||||
}
|
||||
const targetRealPath = safeRealpathSync(resolvedTargetPath);
|
||||
const targetRealPath = safeRealpathSync(resolvedTargetPath, params.realpathCache);
|
||||
if (!targetRealPath) {
|
||||
return params.targetMustExist !== true;
|
||||
}
|
||||
@@ -124,6 +125,7 @@ function resolveManifestPluginSourcePath(params: {
|
||||
entry: string;
|
||||
rejectHardlinks: boolean;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
realpathCache: Map<string, string>;
|
||||
}): string | undefined {
|
||||
const pushDiagnostic = () => {
|
||||
params.diagnostics.push({
|
||||
@@ -140,13 +142,14 @@ function resolveManifestPluginSourcePath(params: {
|
||||
}
|
||||
|
||||
const rootPath = path.resolve(params.rootDir);
|
||||
const rootRealPath = safeRealpathSync(rootPath) ?? rootPath;
|
||||
const rootRealPath = safeRealpathSync(rootPath, params.realpathCache) ?? rootPath;
|
||||
const sourcePath = path.resolve(rootPath, params.entry);
|
||||
if (
|
||||
!isPluginRootPath({
|
||||
rootPath,
|
||||
targetPath: sourcePath,
|
||||
rootRealPath,
|
||||
realpathCache: params.realpathCache,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
targetMustExist: fs.existsSync(sourcePath),
|
||||
})
|
||||
@@ -161,6 +164,7 @@ function resolveManifestPluginSourcePath(params: {
|
||||
rootPath,
|
||||
targetPath: resolvedSourcePath,
|
||||
rootRealPath,
|
||||
realpathCache: params.realpathCache,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
targetMustExist: fs.existsSync(resolvedSourcePath),
|
||||
})
|
||||
@@ -512,6 +516,7 @@ function buildRecord(params: {
|
||||
manifestPath: string;
|
||||
diagnostics: PluginDiagnostic[];
|
||||
rejectHardlinks: boolean;
|
||||
realpathCache: Map<string, string>;
|
||||
schemaCacheKey?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
bundledChannelConfigCollector?: BundledChannelConfigCollector;
|
||||
@@ -577,6 +582,7 @@ function buildRecord(params: {
|
||||
entry: providerSourceEntry.entry,
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
diagnostics: params.diagnostics,
|
||||
realpathCache: params.realpathCache,
|
||||
})
|
||||
: undefined,
|
||||
modelSupport: params.manifest.modelSupport,
|
||||
@@ -1160,6 +1166,7 @@ export function loadPluginManifestRegistry(
|
||||
manifestPath: manifestRes.manifestPath,
|
||||
diagnostics,
|
||||
rejectHardlinks,
|
||||
realpathCache,
|
||||
schemaCacheKey,
|
||||
configSchema,
|
||||
trustedOfficialInstall: isTrustedOfficialPluginInstall({
|
||||
|
||||
@@ -1043,6 +1043,35 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]);
|
||||
});
|
||||
|
||||
it("resolves a persisted bundled root only once per registry load", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const packageRoot = path.join(tempRoot, "openclaw");
|
||||
const bundledRoot = path.join(packageRoot, "dist", "extensions");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_VERSION: "2026.4.26",
|
||||
VITEST: "true",
|
||||
};
|
||||
const pluginIds = ["bundled-one", "bundled-two", "bundled-three", "bundled-four"];
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
writeBundledPlugin(path.join(bundledRoot, pluginId), pluginId, "index.js");
|
||||
}
|
||||
const index = loadInstalledPluginIndex({ config: {}, env, stateDir });
|
||||
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
||||
const realpathSpy = vi.spyOn(fs, "realpathSync");
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId).toSorted()).toEqual(
|
||||
pluginIds.toSorted(),
|
||||
);
|
||||
expect(realpathSpy.mock.calls.filter(([filePath]) => filePath === bundledRoot)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats a persisted source bundled root as stale once its built peer appears", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const packageRoot = path.join(tempRoot, "openclaw");
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "./installed-plugin-index.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js";
|
||||
import { safeRealpathSync } from "./path-safety.js";
|
||||
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
|
||||
import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
|
||||
|
||||
@@ -216,6 +217,7 @@ function hasMismatchedPersistedConfigPathPlugins(
|
||||
index: InstalledPluginIndex,
|
||||
params: LoadPluginRegistryParams,
|
||||
env: NodeJS.ProcessEnv,
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
const loadPaths = normalizePluginsConfig(params.config?.plugins).loadPaths;
|
||||
const discovery = discoverConfiguredPluginLoadPaths({
|
||||
@@ -230,22 +232,18 @@ function hasMismatchedPersistedConfigPathPlugins(
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index),
|
||||
}).plugins.map((plugin) => resolveComparablePath(plugin.rootDir));
|
||||
}).plugins.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache));
|
||||
const persistedRoots = index.plugins
|
||||
.filter((plugin) => plugin.origin === "config")
|
||||
.map((plugin) => resolveComparablePath(plugin.rootDir));
|
||||
.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache));
|
||||
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 resolveComparablePath(filePath: string, realpathCache: Map<string, string>): string {
|
||||
return safeRealpathSync(filePath, realpathCache) ?? path.resolve(filePath);
|
||||
}
|
||||
|
||||
function isRelativePathInsideOrEqual(relativePath: string): boolean {
|
||||
@@ -257,10 +255,14 @@ function isRelativePathInsideOrEqual(relativePath: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(childPath: string, parentPath: string): boolean {
|
||||
function isPathInsideOrEqual(
|
||||
childPath: string,
|
||||
parentPath: string,
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
const relative = path.relative(
|
||||
resolveComparablePath(parentPath),
|
||||
resolveComparablePath(childPath),
|
||||
resolveComparablePath(parentPath, realpathCache),
|
||||
resolveComparablePath(childPath, realpathCache),
|
||||
);
|
||||
return isRelativePathInsideOrEqual(relative);
|
||||
}
|
||||
@@ -268,6 +270,7 @@ function isPathInsideOrEqual(childPath: string, parentPath: string): boolean {
|
||||
function hasMismatchedPersistedBundledPluginRoot(
|
||||
index: InstalledPluginIndex,
|
||||
env: NodeJS.ProcessEnv,
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
const bundledPluginsDir = resolveBundledPluginsDir(env);
|
||||
if (!bundledPluginsDir) {
|
||||
@@ -282,7 +285,12 @@ function hasMismatchedPersistedBundledPluginRoot(
|
||||
bundledRoot: bundledPluginsDir,
|
||||
env,
|
||||
});
|
||||
return !isAllowedPersistedBundledPluginRoot(plugin, bundledPluginsDir, sourceOverlayDirs);
|
||||
return !isAllowedPersistedBundledPluginRoot(
|
||||
plugin,
|
||||
bundledPluginsDir,
|
||||
sourceOverlayDirs,
|
||||
realpathCache,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,28 +298,33 @@ function isAllowedPersistedBundledPluginRoot(
|
||||
plugin: InstalledPluginIndexRecord,
|
||||
bundledPluginsDir: string,
|
||||
sourceOverlayDirs: readonly string[],
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
const pluginRootDir = plugin.rootDir;
|
||||
const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir);
|
||||
if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir)) {
|
||||
if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir, realpathCache)) {
|
||||
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
|
||||
return true;
|
||||
}
|
||||
const relativePluginRoot = path.relative(
|
||||
resolveComparablePath(bundledPluginsDir),
|
||||
resolveComparablePath(pluginRootDir),
|
||||
resolveComparablePath(bundledPluginsDir, realpathCache),
|
||||
resolveComparablePath(pluginRootDir, realpathCache),
|
||||
);
|
||||
return !sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot));
|
||||
}
|
||||
if (sourceOverlayDirs.some((overlayDir) => isPathInsideOrEqual(pluginRootDir, overlayDir))) {
|
||||
if (
|
||||
sourceOverlayDirs.some((overlayDir) =>
|
||||
isPathInsideOrEqual(pluginRootDir, overlayDir, realpathCache),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
|
||||
return false;
|
||||
}
|
||||
const relativePluginRoot = path.relative(
|
||||
resolveComparablePath(legacyRoot),
|
||||
resolveComparablePath(pluginRootDir),
|
||||
resolveComparablePath(legacyRoot, realpathCache),
|
||||
resolveComparablePath(pluginRootDir, realpathCache),
|
||||
);
|
||||
if (!isRelativePathInsideOrEqual(relativePluginRoot)) {
|
||||
return false;
|
||||
@@ -353,7 +366,10 @@ function hashExistingFile(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRecordPackageJsonPath(plugin: InstalledPluginIndexRecord): string | null {
|
||||
function resolveRecordPackageJsonPath(
|
||||
plugin: InstalledPluginIndexRecord,
|
||||
realpathCache: Map<string, string>,
|
||||
): string | null {
|
||||
const packageJsonPath = plugin.packageJson?.path;
|
||||
if (!packageJsonPath) {
|
||||
return null;
|
||||
@@ -365,8 +381,8 @@ function resolveRecordPackageJsonPath(plugin: InstalledPluginIndexRecord): strin
|
||||
return null;
|
||||
}
|
||||
const realRelative = path.relative(
|
||||
resolveComparablePath(rootDir),
|
||||
resolveComparablePath(resolved),
|
||||
resolveComparablePath(rootDir, realpathCache),
|
||||
resolveComparablePath(resolved, realpathCache),
|
||||
);
|
||||
return isRelativePathInsideOrEqual(realRelative) ? resolved : null;
|
||||
}
|
||||
@@ -384,7 +400,10 @@ function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolea
|
||||
});
|
||||
}
|
||||
|
||||
function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
|
||||
function hasStalePersistedPluginMetadata(
|
||||
index: InstalledPluginIndex,
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
return index.plugins.some((plugin) => {
|
||||
if (!hasOptionalMissingPluginManifestFile(plugin)) {
|
||||
const manifestSignatureMatches = fileSignatureMatches(
|
||||
@@ -398,7 +417,7 @@ function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
|
||||
}
|
||||
}
|
||||
}
|
||||
const packageJsonPath = resolveRecordPackageJsonPath(plugin);
|
||||
const packageJsonPath = resolveRecordPackageJsonPath(plugin, realpathCache);
|
||||
if (!plugin.packageJson?.hash) {
|
||||
return false;
|
||||
}
|
||||
@@ -476,6 +495,9 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
if (memo) {
|
||||
return memo;
|
||||
}
|
||||
// Bound canonical paths to this registry build; lifecycle changes must
|
||||
// never reuse security-sensitive symlink or plugin-root resolutions.
|
||||
const realpathCache = new Map<string, string>();
|
||||
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
|
||||
const disabledByCaller = params.preferPersisted === false;
|
||||
const persistedReadsEnabled = !disabledByCaller;
|
||||
@@ -501,14 +523,16 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
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)) {
|
||||
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env, realpathCache)) {
|
||||
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)) {
|
||||
} else if (
|
||||
hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env, realpathCache)
|
||||
) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
@@ -529,7 +553,7 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
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)) {
|
||||
} else if (hasStalePersistedPluginMetadata(persistedIndex, realpathCache)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
|
||||
Reference in New Issue
Block a user