diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index f4bef7dc8eae..6726df0683dd 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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"); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index e794c36c594a..ce1a3bfdf7c0 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -92,6 +92,7 @@ function isPluginRootPath(params: { rootPath: string; targetPath: string; rootRealPath: string; + realpathCache: Map; 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 | 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; schemaCacheKey?: string; configSchema?: Record; 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({ diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index a932ef70b56c..07ee5bd28c75 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -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"); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index aadb28bd4e5f..a9a718e1076d 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -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, ): 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 { + 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, +): 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, ): 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, ): 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 | 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, +): 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(); 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",