From f77d2ec3a80b158a810fd12b7ac4a8d5eb7e07a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 23:26:11 -0700 Subject: [PATCH] fix: stop repeated static catalog manifest scans (#125090) --- ...odel.static-catalog.snapshot-cache.test.ts | 52 +++++++++ .../model.static-catalog.test.ts | 2 + .../model.static-catalog.ts | 38 +++---- src/agents/tools/image-tool.test-support.ts | 2 + src/agents/tools/image-tool.test.ts | 100 ++++++++++++++++++ src/agents/tools/image-tool.ts | 13 ++- src/plugins/manifest-metadata-scan.test.ts | 52 ++++++++- src/plugins/manifest-metadata-scan.ts | 48 +++------ .../manifest-model-id-normalization.test.ts | 2 +- 9 files changed, 254 insertions(+), 55 deletions(-) diff --git a/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts index 32af752e82a1..898e5e2bd53f 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.snapshot-cache.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; const manifestMocks = vi.hoisted(() => ({ @@ -108,6 +109,7 @@ function setManifestPlugins(plugins: unknown[]) { } beforeEach(() => { + clearPluginMetadataLifecycleCaches(); for (const mock of Object.values(manifestMocks)) { mock.mockReset(); } @@ -284,6 +286,56 @@ describe("bundled static model catalog snapshot cache", () => { expect(manifestMocks.loadPluginManifest).toHaveBeenCalledTimes(1); }); + it("refreshes the no-snapshot memo only at the plugin metadata lifecycle boundary", () => { + const env = { HOME: "/custom-home" }; + const plugin = createMistralManifestPlugin(); + setManifestPlugins([ + { ...plugin, modelCatalog: { ...plugin.modelCatalog, runtimeAugment: true } }, + ]); + + expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe(true); + expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe(true); + expect(manifestMocks.listOpenClawPluginManifestMetadata).toHaveBeenCalledTimes(1); + + setManifestPlugins([ + { ...plugin, modelCatalog: { ...plugin.modelCatalog, runtimeAugment: false } }, + ]); + expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe(true); + + clearPluginMetadataLifecycleCaches(); + expect(bundledStaticCatalogProviderUsesRuntimeAugment({ provider: "mistral", env })).toBe( + false, + ); + expect(manifestMocks.listOpenClawPluginManifestMetadata).toHaveBeenCalledTimes(2); + }); + + it("refreshes a retained no-snapshot resolver at the plugin metadata lifecycle boundary", () => { + const env = { HOME: "/custom-home" }; + const firstPlugin = createMistralManifestPlugin(); + setManifestPlugins([firstPlugin]); + const resolveModel = createBundledStaticCatalogModelResolver({ env }); + + expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-3-5" })?.id).toBe( + "mistral-medium-3-5", + ); + + const replacementPlugin = createMistralManifestPlugin(); + replacementPlugin.modelCatalog.providers.mistral.models = + replacementPlugin.modelCatalog.providers.mistral.models.map((model) => ({ + ...model, + id: "mistral-medium-next", + name: "Mistral Medium Next", + })); + setManifestPlugins([replacementPlugin]); + clearPluginMetadataLifecycleCaches(); + + expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-3-5" })).toBeUndefined(); + expect(resolveModel({ provider: "mistral", modelId: "mistral-medium-next" })?.name).toBe( + "Mistral Medium Next", + ); + expect(manifestMocks.listOpenClawPluginManifestMetadata).toHaveBeenCalledTimes(2); + }); + it("preserves plugin enablement policy for current snapshot catalog rows", () => { setCurrentManifestPlugins([createMistralManifestPlugin()]); diff --git a/src/agents/embedded-agent-runner/model.static-catalog.test.ts b/src/agents/embedded-agent-runner/model.static-catalog.test.ts index e83ad45359e3..ae561ae3f0dc 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.test.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.test.ts @@ -48,6 +48,7 @@ vi.mock("../../plugins/provider-discovery.js", async (importOriginal) => ({ runProviderStaticCatalog: providerMocks.runProviderStaticCatalog, })); +import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js"; import { getModelProviderRequestTransport } from "../provider-request-config.js"; import { createBundledProviderStaticCatalogContextResolver, @@ -192,6 +193,7 @@ function expectManifestAliasResolution( } beforeEach(() => { + clearPluginMetadataLifecycleCaches(); manifestMocks.getCurrentPluginMetadataSnapshot.mockReset(); manifestMocks.listOpenClawPluginManifestMetadata.mockReset(); manifestMocks.loadPluginManifest.mockReset(); diff --git a/src/agents/embedded-agent-runner/model.static-catalog.ts b/src/agents/embedded-agent-runner/model.static-catalog.ts index 98ccfad1a421..d55ea6d492ae 100644 --- a/src/agents/embedded-agent-runner/model.static-catalog.ts +++ b/src/agents/embedded-agent-runner/model.static-catalog.ts @@ -12,6 +12,7 @@ import { listOpenClawPluginManifestMetadata } from "../../plugins/manifest-metad import { passesManifestOwnerBasePolicy } from "../../plugins/manifest-owner-policy.js"; import { loadPluginManifestRegistryCore } from "../../plugins/manifest-registry.js"; import { loadPluginManifest } from "../../plugins/manifest.js"; +import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import { normalizePluginDiscoveryResult, @@ -145,14 +146,20 @@ type BundledStaticCatalogState = { plans: Map>; }; -// Snapshot identity changes at the Gateway reload commit, so old provider plans -// cannot survive into a replacement plugin generation. -const bundledStaticCatalogStatesBySnapshot = new WeakMap< - PluginMetadataSnapshot, +let bundledStaticCatalogStatesByOwner = new WeakMap< + object, WeakMap >(); const defaultBundledStaticCatalogConfig: OpenClawConfig = {}; +function clearBundledStaticCatalogStates(): void { + bundledStaticCatalogStatesByOwner = new WeakMap(); +} + +// Snapshot or environment identity pins one plugin generation; install/reload +// owners replace this map so retained resolvers cannot keep stale provider plans. +registerPluginMetadataProcessMemoLifecycleClear(clearBundledStaticCatalogStates); + function resolveBundledStaticCatalogMetadataSnapshot( params: BundledStaticCatalogParams, ): PluginMetadataSnapshot | undefined { @@ -203,14 +210,15 @@ function listBundledStaticCatalogPlugins( ); } -function resolveSnapshotBundledStaticCatalogState( +function resolveBundledStaticCatalogState( params: BundledStaticCatalogParams, - metadataSnapshot: PluginMetadataSnapshot, + metadataSnapshot?: PluginMetadataSnapshot, ): BundledStaticCatalogState { - let states = bundledStaticCatalogStatesBySnapshot.get(metadataSnapshot); + const owner = metadataSnapshot ?? params.env; + let states = bundledStaticCatalogStatesByOwner.get(owner); if (!states) { states = new WeakMap(); - bundledStaticCatalogStatesBySnapshot.set(metadataSnapshot, states); + bundledStaticCatalogStatesByOwner.set(owner, states); } const config = params.cfg ?? defaultBundledStaticCatalogConfig; const cached = states.get(config); @@ -230,6 +238,7 @@ export function bundledStaticCatalogProviderUsesRuntimeAugment(params: { provider: string; cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; + metadataSnapshot?: PluginMetadataSnapshot; workspaceDir?: string; }): boolean { const provider = normalizeProviderId(params.provider); @@ -239,12 +248,11 @@ export function bundledStaticCatalogProviderUsesRuntimeAugment(params: { const catalogParams = { cfg: params.cfg, env: params.env ?? process.env, + ...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}), workspaceDir: params.workspaceDir, }; const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot(catalogParams); - const plugins = metadataSnapshot - ? resolveSnapshotBundledStaticCatalogState(catalogParams, metadataSnapshot).plugins - : listBundledStaticCatalogPlugins(catalogParams); + const plugins = resolveBundledStaticCatalogState(catalogParams, metadataSnapshot).plugins; return plugins.some((plugin) => { const catalog = plugin.modelCatalog; if (catalog?.runtimeAugment !== true) { @@ -305,19 +313,13 @@ export function createBundledStaticCatalogModelResolver(params?: { const matchesStaticModelId = params?.metadataSnapshot ? createStaticModelIdMatcher({ manifestPlugins: params.metadataSnapshot.plugins }) : staticModelIdMatches; - let standaloneState: BundledStaticCatalogState | undefined; return (lookup) => { const provider = normalizeProviderId(lookup.provider); if (!provider || !lookup.modelId.trim()) { return undefined; } const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot(catalogParams); - const state = metadataSnapshot - ? resolveSnapshotBundledStaticCatalogState(catalogParams, metadataSnapshot) - : (standaloneState ??= { - plugins: listBundledStaticCatalogPlugins(catalogParams), - plans: new Map(), - }); + const state = resolveBundledStaticCatalogState(catalogParams, metadataSnapshot); if (state.plugins.length === 0) { return undefined; } diff --git a/src/agents/tools/image-tool.test-support.ts b/src/agents/tools/image-tool.test-support.ts index 0915fac1d5a0..4d9056823c07 100644 --- a/src/agents/tools/image-tool.test-support.ts +++ b/src/agents/tools/image-tool.test-support.ts @@ -13,6 +13,7 @@ import type { describeImagesWithModel, MediaUnderstandingProvider, } from "../../plugin-sdk/media-understanding.js"; +import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import type { resolveBundledStaticCatalogModel } from "../embedded-agent-runner/model.static-catalog.js"; import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js"; @@ -51,6 +52,7 @@ type ResolveImageCompressionPolicy = (params: { imageCount: number; agentDir?: string; workspaceDir?: string; + metadataSnapshot?: PluginMetadataSnapshot; }) => Promise; type ImageToolProviderDeps = { diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index ad8ff50d19d1..b730239072dc 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -8,6 +8,7 @@ import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { encodePngRgba, fillPixel } from "../../media/png-encode.js"; import type { @@ -15,6 +16,8 @@ import type { ImagesDescriptionRequest, MediaUnderstandingProvider, } from "../../plugin-sdk/media-understanding.js"; +import { installTemporaryCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js"; @@ -3149,6 +3152,103 @@ describe("image compression policy", () => { testing.setProviderDepsForTest(); }); + it("keeps runtime augmentation pinned to the prepared plugin generation", async () => { + const provider = "prepared-image-provider"; + const model = "prepared-image-model"; + const cfg = { + models: { + providers: { + [provider]: { + baseUrl: "https://prepared-image.example.test/v1", + api: "openai-completions", + models: [ + { + id: model, + name: "Prepared image model", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }, + }, + }, + } satisfies OpenClawConfig; + const createSnapshot = (runtimeAugment: boolean) => { + const plugin = { + id: "prepared-image-plugin", + enabledByDefault: true, + channels: [], + providers: [provider], + cliBackends: [], + skills: [], + hooks: [], + origin: "bundled", + rootDir: "/fake/prepared-image-plugin", + source: "/fake/prepared-image-plugin/index.js", + manifestPath: "/fake/prepared-image-plugin/openclaw.plugin.json", + modelCatalog: { + runtimeAugment, + providers: { + [provider]: { + baseUrl: "https://prepared-image.example.test/v1", + api: "openai-completions", + models: [{ id: model, name: "Prepared image model" }], + }, + }, + }, + } satisfies PluginManifestRecord; + return createPluginMetadataSnapshot({ + config: cfg, + manifestRegistry: { plugins: [plugin], diagnostics: [] }, + }); + }; + const preparedSnapshot = createSnapshot(false); + const currentSnapshot = createSnapshot(true); + const hookModes: Array = []; + installImageUnderstandingProviderDeps([], { + resolveModelAsync: async (resolvedProvider, resolvedModel, _agentDir, _cfg, options) => { + hookModes.push(options?.skipProviderRuntimeHooks); + return { + model: { + id: resolvedModel, + provider: resolvedProvider, + input: ["text", "image"], + mediaInput: { + image: options?.skipProviderRuntimeHooks + ? { maxBytes: 1_000_000 } + : { maxSidePx: 4096 }, + }, + } as never, + authStorage: {} as never, + modelRegistry: {} as never, + }; + }, + }); + const currentLease = installTemporaryCurrentPluginMetadataSnapshot(currentSnapshot, { + config: cfg, + }); + + try { + await expect( + testing.resolveImageCompressionPolicy({ + cfg, + imageModelConfig: { primary: `${provider}/${model}` }, + imageCount: 1, + metadataSnapshot: preparedSnapshot, + }), + ).resolves.toEqual({ + imageCount: 1, + models: [{ maxBytes: 1_000_000 }], + }); + expect(hookModes).toEqual([true]); + } finally { + currentLease.release(); + } + }); + it("derives model metadata, quality preference, and image count from config", async () => { const cfg = { ...cfgWithImageModelMetadata, diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 386dfe54e41c..3195af6a6751 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -496,9 +496,6 @@ function providerUsesRuntimeModelAugment(params: { if (!provider) { return false; } - if (bundledStaticCatalogProviderUsesRuntimeAugment({ provider })) { - return true; - } const config = params.cfg ?? {}; const preparedSnapshot = params.metadataSnapshot && @@ -518,6 +515,16 @@ function providerUsesRuntimeModelAugment(params: { env: process.env, ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), }); + if ( + bundledStaticCatalogProviderUsesRuntimeAugment({ + provider, + cfg: params.cfg, + ...(snapshot ? { metadataSnapshot: snapshot } : {}), + workspaceDir: params.workspaceDir, + }) + ) { + return true; + } if (!snapshot) { return false; } diff --git a/src/plugins/manifest-metadata-scan.test.ts b/src/plugins/manifest-metadata-scan.test.ts index 113911e20ec8..de2ab9f413dc 100644 --- a/src/plugins/manifest-metadata-scan.test.ts +++ b/src/plugins/manifest-metadata-scan.test.ts @@ -2,10 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js"; import { loadPluginManifest } from "./manifest.js"; +import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; const tempRoots: string[] = []; @@ -22,11 +23,60 @@ function writeJson(filePath: string, value: unknown): void { describe("listOpenClawPluginManifestMetadata", () => { afterEach(() => { + vi.restoreAllMocks(); + clearPluginMetadataLifecycleCaches(); for (const root of tempRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } }); + it("keeps manifest metadata stable until explicit lifecycle invalidation", () => { + const root = createTempRoot(); + const home = path.join(root, "home"); + const bundledRoot = path.join(root, "extensions"); + const pluginDir = path.join(bundledRoot, "lifecycle-catalog"); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + const env = { + HOME: home, + OPENCLAW_HOME: home, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }; + const writeManifest = (generation: string) => + writeJson(manifestPath, { id: "lifecycle-catalog", generation }); + + writeManifest("first"); + clearPluginMetadataLifecycleCaches(); + const statSpy = vi.spyOn(fs, "statSync"); + const readdirSpy = vi.spyOn(fs, "readdirSync"); + + expect( + listOpenClawPluginManifestMetadata(env).find( + (record) => record.manifest.id === "lifecycle-catalog", + )?.manifest.generation, + ).toBe("first"); + const firstStatCalls = statSpy.mock.calls.length; + const firstReaddirCalls = readdirSpy.mock.calls.length; + expect(firstReaddirCalls).toBeGreaterThan(0); + + writeManifest("second"); + expect( + listOpenClawPluginManifestMetadata(env).find( + (record) => record.manifest.id === "lifecycle-catalog", + )?.manifest.generation, + ).toBe("first"); + expect(statSpy).toHaveBeenCalledTimes(firstStatCalls); + expect(readdirSpy).toHaveBeenCalledTimes(firstReaddirCalls); + + clearPluginMetadataLifecycleCaches(); + expect( + listOpenClawPluginManifestMetadata(env).find( + (record) => record.manifest.id === "lifecycle-catalog", + )?.manifest.generation, + ).toBe("second"); + expect(statSpy).toHaveBeenCalledTimes(firstStatCalls); + expect(readdirSpy.mock.calls.length).toBeGreaterThan(firstReaddirCalls); + }); + it("prefers the active bundled manifest over stale persisted bundled installs", () => { const root = createTempRoot(); const home = path.join(root, "home"); diff --git a/src/plugins/manifest-metadata-scan.ts b/src/plugins/manifest-metadata-scan.ts index 087d18a0eb02..85667b268ac4 100644 --- a/src/plugins/manifest-metadata-scan.ts +++ b/src/plugins/manifest-metadata-scan.ts @@ -12,6 +12,7 @@ import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { resolveDefaultPluginExtensionsDir } from "./install-paths.js"; import { readPersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; +import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; // Plugin manifest files are small metadata descriptors. Bound reads to prevent // a corrupted or hostile manifest from exhausting memory during metadata scan. @@ -33,12 +34,15 @@ type CandidateDir = { }; const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json"; -let manifestMetadataCache: - | { - key: string; - records: PluginManifestMetadataRecord[]; - } - | undefined; +let manifestMetadataCache = new WeakMap(); + +function clearManifestMetadataCache(): void { + manifestMetadataCache = new WeakMap(); +} + +// Manifest metadata is process-stable; install/reload owners refresh it only +// through the shared plugin metadata lifecycle boundary. +registerPluginMetadataProcessMemoLifecycleClear(clearManifestMetadataCache); function listChildPluginDirs( root: string | undefined, @@ -88,16 +92,6 @@ function readManifestObject(pluginDir: string): Record | undefi return readJsonObject(path.join(pluginDir, PLUGIN_MANIFEST_FILENAME)); } -function manifestFileFingerprint(pluginDir: string): string { - const manifestPath = path.join(pluginDir, PLUGIN_MANIFEST_FILENAME); - try { - const stat = fs.statSync(manifestPath); - return `${manifestPath}:${stat.mtimeMs}:${stat.size}`; - } catch { - return `${manifestPath}:missing`; - } -} - function listPersistedIndexPluginDirs(env: NodeJS.ProcessEnv, startOrder: number): CandidateDir[] { const index = readPersistedInstalledPluginIndexSync({ env }); if (!index) { @@ -174,6 +168,10 @@ function uniqueCandidateDirs(candidates: CandidateDir[]): CandidateDir[] { export function listOpenClawPluginManifestMetadata( env: NodeJS.ProcessEnv = process.env, ): PluginManifestMetadataRecord[] { + const cached = manifestMetadataCache.get(env); + if (cached) { + return cached.slice(); + } const candidates: CandidateDir[] = []; let order = 0; candidates.push(...listPersistedIndexPluginDirs(env, order)); @@ -185,21 +183,7 @@ export function listOpenClawPluginManifestMetadata( candidates.push( ...listChildPluginDirs(resolveDefaultPluginExtensionsDir(env), 4, order, "global"), ); - const uniqueCandidates = uniqueCandidateDirs(candidates); - const cacheKey = JSON.stringify( - uniqueCandidates.map((candidate) => [ - candidate.pluginDir, - candidate.rank, - candidate.order, - candidate.origin ?? "", - manifestFileFingerprint(candidate.pluginDir), - ]), - ); - if (manifestMetadataCache?.key === cacheKey) { - return manifestMetadataCache.records.slice(); - } - const byManifestId = new Map(); const records: PluginManifestMetadataRecord[] = []; for (const candidate of uniqueCandidates) { @@ -217,6 +201,6 @@ export function listOpenClawPluginManifestMetadata( } records.push({ pluginDir: candidate.pluginDir, manifest, origin: candidate.origin }); } - manifestMetadataCache = { key: cacheKey, records }; - return records; + manifestMetadataCache.set(env, records); + return records.slice(); } diff --git a/src/plugins/manifest-model-id-normalization.test.ts b/src/plugins/manifest-model-id-normalization.test.ts index 4251645e4c1c..ec044d97e534 100644 --- a/src/plugins/manifest-model-id-normalization.test.ts +++ b/src/plugins/manifest-model-id-normalization.test.ts @@ -129,7 +129,7 @@ describe("manifest model id normalization", () => { expect(normalizeDemoModel()).toBe("charlie/demo-model"); }); - it("reuses manifest metadata while file fingerprints are unchanged", () => { + it("reuses manifest metadata for the same environment identity", () => { const stateDir = tempDirs.make("openclaw-model-id-normalization-"); const pluginDir = path.join(stateDir, "extensions", "normalizer"); writeInstallIndex({ stateDir, pluginDir });