diff --git a/src/config/runtime-schema.ts b/src/config/runtime-schema.ts index d4dd138dcf3..77830c05d74 100644 --- a/src/config/runtime-schema.ts +++ b/src/config/runtime-schema.ts @@ -11,7 +11,7 @@ import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; function loadManifestRegistry(config: OpenClawConfig, env?: NodeJS.ProcessEnv) { const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); - const currentSnapshot = getCurrentPluginMetadataSnapshot({ config, workspaceDir }); + const currentSnapshot = getCurrentPluginMetadataSnapshot({ config, env, workspaceDir }); if (currentSnapshot) { return currentSnapshot.manifestRegistry; } diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index 9b12b241ba0..3f65a41ad31 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -100,6 +100,23 @@ describe("current plugin metadata snapshot", () => { ).toBeUndefined(); }); + it("rejects a current snapshot when env-resolved plugin load paths change", () => { + const config = { plugins: { load: { paths: ["~/plugins"] } } }; + const snapshot = createSnapshot({ config }); + const snapshotEnv = { + HOME: "/home/snapshot", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + const requestedEnv = { + HOME: "/home/requested", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + setCurrentPluginMetadataSnapshot(snapshot, { config, env: snapshotEnv }); + + expect(getCurrentPluginMetadataSnapshot({ config, env: snapshotEnv })).toBe(snapshot); + expect(getCurrentPluginMetadataSnapshot({ config, env: requestedEnv })).toBeUndefined(); + }); + it("keeps source-policy compatibility when storing an auto-enabled runtime config", () => { const sourceConfig = { channels: { telegram: { botToken: "token" } } }; const autoEnabledConfig = { diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index abe686cb42a..0e4d13c69e7 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -5,36 +5,21 @@ import { setCurrentPluginMetadataSnapshotState, } from "./current-plugin-metadata-state.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; +import { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; - -function normalizeLoadPaths(config: OpenClawConfig | undefined): readonly string[] { - const paths = config?.plugins?.load?.paths; - if (!Array.isArray(paths)) { - return []; - } - return paths.filter((entry) => typeof entry === "string"); -} - -export function resolvePluginMetadataSnapshotConfigFingerprint( - config: OpenClawConfig | undefined, - options: { policyHash?: string } = {}, -): string { - return JSON.stringify({ - policyHash: options.policyHash ?? resolveInstalledPluginIndexPolicyHash(config), - pluginLoadPaths: normalizeLoadPaths(config), - }); -} +export { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js"; // Single-slot Gateway-owned handoff. Replace or clear it at lifecycle boundaries; // never accumulate historical metadata snapshots here. export function setCurrentPluginMetadataSnapshot( snapshot: PluginMetadataSnapshot | undefined, - options: { config?: OpenClawConfig } = {}, + options: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv } = {}, ): void { setCurrentPluginMetadataSnapshotState( snapshot, snapshot ? resolvePluginMetadataSnapshotConfigFingerprint(options.config, { + env: options.env, policyHash: snapshot.policyHash, }) : undefined, @@ -48,6 +33,7 @@ export function clearCurrentPluginMetadataSnapshot(): void { export function getCurrentPluginMetadataSnapshot( params: { config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; workspaceDir?: string; } = {}, ): PluginMetadataSnapshot | undefined { @@ -62,12 +48,19 @@ export function getCurrentPluginMetadataSnapshot( ) { return undefined; } - if ( - params.config && - configFingerprint && - configFingerprint !== resolvePluginMetadataSnapshotConfigFingerprint(params.config) - ) { - return undefined; + if (params.config) { + const requestedConfigFingerprint = resolvePluginMetadataSnapshotConfigFingerprint( + params.config, + { + env: params.env, + }, + ); + if (configFingerprint && configFingerprint !== requestedConfigFingerprint) { + return undefined; + } + if (snapshot.configFingerprint && snapshot.configFingerprint !== requestedConfigFingerprint) { + return undefined; + } } if (snapshot.workspaceDir !== undefined && params.workspaceDir === undefined) { return undefined; diff --git a/src/plugins/plugin-lookup-table.test.ts b/src/plugins/plugin-lookup-table.test.ts index 8ce6542cb30..03cf0ef7be3 100644 --- a/src/plugins/plugin-lookup-table.test.ts +++ b/src/plugins/plugin-lookup-table.test.ts @@ -303,6 +303,112 @@ describe("loadPluginLookUpTable", () => { ); }); + it("rebuilds when a provided metadata snapshot has stale plugin load paths", async () => { + const plugins = [ + createManifestRecord({ + id: "telegram", + origin: "bundled", + channels: ["telegram"], + }), + ]; + const snapshotConfig = { + plugins: { + load: { paths: ["/plugins/one"] }, + }, + } as OpenClawConfig; + const requestedConfig = { + plugins: { + load: { paths: ["/plugins/two"] }, + }, + } as OpenClawConfig; + const policyHash = resolveInstalledPluginIndexPolicyHash(snapshotConfig); + const index = createIndex(plugins, { policyHash }); + const manifestRegistry: PluginManifestRegistry = { + plugins, + diagnostics: [], + }; + loadPluginManifestRegistryForInstalledIndex.mockReturnValue(manifestRegistry); + const { loadPluginMetadataSnapshot } = await import("./plugin-metadata-snapshot.js"); + const { loadPluginLookUpTable } = await import("./plugin-lookup-table.js"); + + const metadataSnapshot = loadPluginMetadataSnapshot({ + config: snapshotConfig, + env: {}, + index, + }); + loadPluginManifestRegistryForInstalledIndex.mockClear(); + + loadPluginLookUpTable({ + config: requestedConfig, + env: {}, + index, + metadataSnapshot, + }); + + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith( + expect.objectContaining({ + index, + config: requestedConfig, + }), + ); + }); + + it("rebuilds when a provided metadata snapshot has stale env-resolved plugin load paths", async () => { + const plugins = [ + createManifestRecord({ + id: "telegram", + origin: "bundled", + channels: ["telegram"], + }), + ]; + const config = { + plugins: { + load: { paths: ["~/plugins"] }, + }, + } as OpenClawConfig; + const snapshotEnv = { + HOME: "/home/snapshot", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + const requestedEnv = { + HOME: "/home/requested", + OPENCLAW_HOME: undefined, + } as NodeJS.ProcessEnv; + const policyHash = resolveInstalledPluginIndexPolicyHash(config); + const index = createIndex(plugins, { policyHash }); + const manifestRegistry: PluginManifestRegistry = { + plugins, + diagnostics: [], + }; + loadPluginManifestRegistryForInstalledIndex.mockReturnValue(manifestRegistry); + const { loadPluginMetadataSnapshot } = await import("./plugin-metadata-snapshot.js"); + const { loadPluginLookUpTable } = await import("./plugin-lookup-table.js"); + + const metadataSnapshot = loadPluginMetadataSnapshot({ + config, + env: snapshotEnv, + index, + }); + loadPluginManifestRegistryForInstalledIndex.mockClear(); + + loadPluginLookUpTable({ + config, + env: requestedEnv, + index, + metadataSnapshot, + }); + + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledOnce(); + expect(loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith( + expect.objectContaining({ + index, + config, + env: requestedEnv, + }), + ); + }); + it("rebuilds when a provided metadata snapshot has stale plugin inventory", async () => { const snapshotPlugins = [ createManifestRecord({ diff --git a/src/plugins/plugin-lookup-table.ts b/src/plugins/plugin-lookup-table.ts index 63d2ac58219..ab09e15057e 100644 --- a/src/plugins/plugin-lookup-table.ts +++ b/src/plugins/plugin-lookup-table.ts @@ -59,6 +59,7 @@ export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): Plug isPluginMetadataSnapshotCompatible({ snapshot: params.metadataSnapshot, config: requestedSnapshotConfig, + env: params.env, workspaceDir: params.workspaceDir, index: params.index, }) diff --git a/src/plugins/plugin-metadata-config-fingerprint.ts b/src/plugins/plugin-metadata-config-fingerprint.ts new file mode 100644 index 00000000000..6614c17d74a --- /dev/null +++ b/src/plugins/plugin-metadata-config-fingerprint.ts @@ -0,0 +1,31 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveHomeRelativePath } from "../infra/home-dir.js"; +import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; + +function normalizeResolvedLoadPaths( + config: OpenClawConfig | undefined, + env: NodeJS.ProcessEnv, +): readonly string[] { + const paths = config?.plugins?.load?.paths; + if (!Array.isArray(paths)) { + return []; + } + return paths.flatMap((entry) => { + if (typeof entry !== "string") { + return []; + } + const trimmed = entry.trim(); + return trimmed ? [resolveHomeRelativePath(trimmed, { env })] : []; + }); +} + +export function resolvePluginMetadataSnapshotConfigFingerprint( + config: OpenClawConfig | undefined, + options: { env?: NodeJS.ProcessEnv; policyHash?: string } = {}, +): string { + const env = options.env ?? process.env; + return JSON.stringify({ + policyHash: options.policyHash ?? resolveInstalledPluginIndexPolicyHash(config), + pluginLoadPaths: normalizeResolvedLoadPaths(config, env), + }); +} diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 472c5172084..a933210e239 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -7,6 +7,7 @@ import { resolveInstalledManifestRegistryIndexFingerprint, } from "./manifest-registry-installed.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; +import { resolvePluginMetadataSnapshotConfigFingerprint } from "./plugin-metadata-config-fingerprint.js"; import type { LoadPluginMetadataSnapshotParams, PluginMetadataSnapshot, @@ -36,13 +37,24 @@ function indexesMatch( } export function isPluginMetadataSnapshotCompatible(params: { - snapshot: Pick; + snapshot: Pick< + PluginMetadataSnapshot, + "configFingerprint" | "index" | "policyHash" | "workspaceDir" + >; config: OpenClawConfig; + env?: NodeJS.ProcessEnv; workspaceDir?: string; index?: InstalledPluginIndex; }): boolean { + const env = params.env ?? process.env; return ( params.snapshot.policyHash === resolveInstalledPluginIndexPolicyHash(params.config) && + (!params.snapshot.configFingerprint || + params.snapshot.configFingerprint === + resolvePluginMetadataSnapshotConfigFingerprint(params.config, { + env, + policyHash: params.snapshot.policyHash, + })) && (params.snapshot.workspaceDir ?? "") === (params.workspaceDir ?? "") && indexesMatch(params.snapshot.index, params.index) ); @@ -171,6 +183,10 @@ function loadPluginMetadataSnapshotImpl( return { policyHash: index.policyHash, + configFingerprint: resolvePluginMetadataSnapshotConfigFingerprint(params.config, { + env: params.env, + policyHash: index.policyHash, + }), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), index, registryDiagnostics: registryResult.diagnostics, diff --git a/src/plugins/plugin-metadata-snapshot.types.ts b/src/plugins/plugin-metadata-snapshot.types.ts index 223dcff7ec5..10eacc1afdc 100644 --- a/src/plugins/plugin-metadata-snapshot.types.ts +++ b/src/plugins/plugin-metadata-snapshot.types.ts @@ -35,6 +35,7 @@ export type PluginMetadataSnapshotRegistryDiagnostic = { export type PluginMetadataSnapshot = { policyHash: string; + configFingerprint?: string; workspaceDir?: string; index: InstalledPluginIndex; registryDiagnostics: readonly PluginMetadataSnapshotRegistryDiagnostic[]; diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index 580828b5e7d..506d55f94b9 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -137,7 +137,7 @@ describe("getCachedPluginModuleLoader", () => { ); }); - it("lets callers intentionally share loaders behind a custom cache scope key", async () => { + it("keeps cache scope keys separated by loader options", async () => { const { createJiti, getCachedPluginModuleLoader } = await loadCachedPluginModuleLoader("cache-scope-key"); @@ -165,6 +165,41 @@ describe("getCachedPluginModuleLoader", () => { cacheScopeKey: "bundled:native", }); + expect(second).not.toBe(first); + first("/repo/dist/extensions/demo-a/api.js"); + second("/repo/dist/extensions/demo-b/api.js"); + expect(createJiti).toHaveBeenCalledTimes(2); + expect(cache.size).toBe(2); + }); + + it("lets callers explicitly share loaders behind an unsafe shared cache scope key", async () => { + const { createJiti, getCachedPluginModuleLoader } = + await loadCachedPluginModuleLoader("shared-cache-scope-key"); + + const cache = new Map(); + const first = getCachedPluginModuleLoader({ + cache, + modulePath: "/repo/dist/extensions/demo-a/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", + aliasMap: { + demo: "/repo/demo-a.js", + }, + tryNative: true, + sharedCacheScopeKey: "bundled:native", + }); + const second = getCachedPluginModuleLoader({ + cache, + modulePath: "/repo/dist/extensions/demo-b/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", + aliasMap: { + demo: "/repo/demo-b.js", + }, + tryNative: true, + sharedCacheScopeKey: "bundled:native", + }); + expect(second).toBe(first); second("/repo/dist/extensions/demo-b/api.js"); expect(createJiti).toHaveBeenCalledTimes(1); diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index 13c7d6d58ad..ffeb43fa6d8 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -24,15 +24,9 @@ export function getCachedPluginModuleLoader(params: { tryNative?: boolean; pluginSdkResolution?: PluginSdkResolutionPreference; cacheScopeKey?: string; + sharedCacheScopeKey?: string; }): PluginModuleLoader { const loaderFilename = toSafeImportPath(params.loaderFilename ?? params.modulePath); - if (params.cacheScopeKey) { - const scopedCacheKey = `${loaderFilename}::${params.cacheScopeKey}`; - const cached = params.cache.get(scopedCacheKey); - if (cached) { - return cached; - } - } const hasAliasOverride = Boolean(params.aliasMap); const hasTryNativeOverride = typeof params.tryNative === "boolean"; const defaultConfig = @@ -71,7 +65,10 @@ export function getCachedPluginModuleLoader(params: { tryNative, aliasMap, }); - const scopedCacheKey = `${loaderFilename}::${params.cacheScopeKey ?? cacheKey}`; + const scopedCacheKey = `${loaderFilename}::${ + params.sharedCacheScopeKey ?? + (params.cacheScopeKey ? `${params.cacheScopeKey}::${cacheKey}` : cacheKey) + }`; const cached = params.cache.get(scopedCacheKey); if (cached) { return cached; diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index d1292f8ec8a..1d86c5335bf 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; @@ -109,6 +110,45 @@ function hasMismatchedPersistedBundledPluginRoot( ); } +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); + return relative.startsWith("..") || path.isAbsolute(relative) ? null : resolved; +} + +function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean { + return index.plugins.some((plugin) => { + if (!hasOptionalMissingPluginManifestFile(plugin)) { + 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 packageJsonHash = hashExistingFile(packageJsonPath); + return packageJsonHash !== plugin.packageJson.hash; + }); +} + export function loadPluginRegistrySnapshotWithMetadata( params: LoadPluginRegistryParams = {}, ): PluginRegistrySnapshotResult { @@ -154,6 +194,13 @@ export function loadPluginRegistrySnapshotWithMetadata( 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 (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 { return { snapshot: persistedIndex, diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index cad8c204791..2d7b83b7d96 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -54,6 +55,10 @@ function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { }; } +function hashFile(filePath: string): string { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + function createCandidate(rootDir: string): PluginCandidate { fs.writeFileSync( path.join(rootDir, "index.ts"), @@ -414,6 +419,7 @@ describe("plugin registry facade", () => { { ...createIndex("persisted").plugins[0], manifestPath: path.join(persistedRootDir, "openclaw.plugin.json"), + manifestHash: hashFile(path.join(persistedRootDir, "openclaw.plugin.json")), source: path.join(persistedRootDir, "index.ts"), rootDir: persistedRootDir, }, @@ -464,6 +470,125 @@ describe("plugin registry facade", () => { ]); }); + it("falls back to the derived registry when persisted manifest metadata is stale", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const candidate = createCandidate(rootDir); + const config = {} as const; + const persisted = loadPluginRegistrySnapshot({ + candidates: [candidate], + config, + env: hermeticEnv(), + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex(persisted, { stateDir }); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "demo", + name: "Demo", + configSchema: { type: "object" }, + providers: ["demo", "demo-next"], + }), + "utf8", + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + config, + env: hermeticEnv(), + }); + + expect(result.source).toBe("derived"); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "persisted-registry-stale-source" }), + ]); + expect(result.snapshot.plugins[0]?.manifestHash).not.toBe(persisted.plugins[0]?.manifestHash); + }); + + it("falls back to the derived registry when persisted package metadata is stale", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + fs.writeFileSync( + path.join(rootDir, "package.json"), + JSON.stringify({ name: "demo-plugin", version: "1.0.0" }), + "utf8", + ); + const candidate = { + ...createCandidate(rootDir), + packageDir: rootDir, + packageName: "demo-plugin", + packageVersion: "1.0.0", + } satisfies PluginCandidate; + const config = {} as const; + const persisted = loadPluginRegistrySnapshot({ + candidates: [candidate], + config, + env: hermeticEnv(), + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex(persisted, { stateDir }); + fs.writeFileSync( + path.join(rootDir, "package.json"), + JSON.stringify({ name: "demo-plugin", version: "1.0.1" }), + "utf8", + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + config, + env: hermeticEnv(), + }); + + expect(result.source).toBe("derived"); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "persisted-registry-stale-source" }), + ]); + expect(result.snapshot.plugins[0]?.packageJson?.hash).not.toBe( + persisted.plugins[0]?.packageJson?.hash, + ); + }); + + it("falls back to the derived registry when persisted package metadata disappears", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + fs.writeFileSync( + path.join(rootDir, "package.json"), + JSON.stringify({ name: "demo-plugin", version: "1.0.0" }), + "utf8", + ); + const candidate = { + ...createCandidate(rootDir), + packageDir: rootDir, + packageName: "demo-plugin", + packageVersion: "1.0.0", + } satisfies PluginCandidate; + const config = {} as const; + const persisted = loadPluginRegistrySnapshot({ + candidates: [candidate], + config, + env: hermeticEnv(), + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex(persisted, { stateDir }); + fs.rmSync(path.join(rootDir, "package.json")); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + config, + env: hermeticEnv(), + }); + + expect(result.source).toBe("derived"); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "persisted-registry-stale-source" }), + ]); + expect(result.snapshot.plugins[0]?.packageJson).toBeUndefined(); + }); + it("falls back to the derived registry when persisted bundled roots point at another checkout", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir();