diff --git a/CHANGELOG.md b/CHANGELOG.md index 580409e4127..0093bb71932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Agents/sessions: mark same-turn `sessions_send` and A2A reply prompts with an inter-session `isUser=false` envelope before they reach the model, so foreign session output no longer lands as bare active user text. Fixes #73702; refs #73698, #73609, #73595, and #73622. Thanks @alvelda. - Outbound/security: strip known internal runtime scaffolding such as `` and `` at the final channel delivery boundary and keep Discord output on targeted tag stripping, so degraded harness replies cannot leak those tags to users. Fixes #73595. Thanks @gabrielexito-stack and @martingarramon. - Security/Telegram: load Telegram security adapters in read-only audit/doctor, audit malformed Telegram DM `allowFrom` entries even when groups are disabled, and keep allowlist DM audits from counting stale pairing-store senders, so public/shared-DM risk checks stay accurate. Refs #73698. Thanks @xace1825. +- Plugins: remove hidden manifest, provider-owner, bootstrap, and channel metadata caches so plugin installs, manifest edits, and bundled-root changes are visible on the next metadata read while keeping runtime/module loader caches for actual plugin code. Thanks @shakkernerd. - CLI/plugins: use plugin metadata snapshots for install slot selection and add opt-in plugin lifecycle timing traces, so plugin install avoids runtime-loading the plugin registry for metadata-only decisions. Thanks @shakkernerd. - fix(plugins): restrict bundled plugin dir resolution to trusted package roots. (#73275) Thanks @pgondhi987. - fix(security): prevent workspace PATH injection via service env and trash helpers. (#73264) Thanks @pgondhi987. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 303598ae0b0..5260cfadd39 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -87,35 +87,51 @@ discovery order. When setup runtime does execute, registry diagnostics report drift between `setup.providers` / `setup.cliBackends` and the providers or CLI backends registered by setup-api without blocking legacy plugins. -### What the loader caches +### Plugin cache boundary -OpenClaw keeps short in-process caches for: +OpenClaw does not cache plugin discovery results or direct manifest registry +data behind wall-clock windows. Installs, manifest edits, and load-path changes +must become visible on the next explicit metadata read or snapshot rebuild. + +The safe metadata fast path is explicit object ownership, not a hidden cache. +Gateway startup hot paths should pass the current `PluginMetadataSnapshot`, the +derived `PluginLookUpTable`, or an explicit manifest registry through the call +chain. Config validation, startup auto-enable, plugin bootstrap, setup lookup, +and provider selection can reuse those objects while they represent the current +config and plugin inventory. When that input changes, rebuild and replace the +snapshot instead of mutating it or keeping historical copies. +Views over the active plugin registry and bundled channel bootstrap helpers +should be recomputed from the current registry/root. Short-lived maps are fine +inside one call to dedupe work or guard reentry; they must not become process +metadata caches. + +For plugin loading, the persistent cache layer is runtime loading. It may reuse +loader state when code or installed artifacts are actually loaded, such as: + +- `PluginLoaderCacheState` and compatible active runtime registries +- jiti/module caches and public-surface loader caches used to avoid importing + the same runtime surface repeatedly +- runtime dependency mirrors and filesystem caches for installed plugin + artifacts +- short-lived per-call maps for path normalization or duplicate resolution + +Those caches are data-plane implementation details. They must not answer +control-plane questions such as "which plugin owns this provider?" unless the +caller deliberately asked for runtime loading. + +Do not add persistent or wall-clock caches for: - discovery results -- manifest registry data -- loaded plugin registries +- direct manifest registries +- manifest registries reconstructed from the installed plugin index +- provider owner lookup, model suppression, provider policy, or public-artifact + metadata +- any other manifest-derived answer where a changed manifest, installed index, + or load path should be visible on the next metadata read -These caches reduce bursty startup and repeated command overhead. They are safe -to think of as short-lived performance caches, not persistence. - -Gateway startup hot paths should prefer the current `PluginMetadataSnapshot`, -the derived `PluginLookUpTable`, or an explicit manifest registry passed through -the call chain. Config validation, startup auto-enable, and plugin bootstrap use -the same snapshot when available. For callers that still rebuild manifest -metadata from the persisted installed plugin index, OpenClaw also keeps a small -bounded fallback cache keyed by the installed index, request shape, config -policy, runtime roots, and manifest/package file signatures. That cache is only a -fallback for repeated installed-index reconstruction; it is not a mutable runtime -plugin registry. - -Performance note: - -- Set `OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE=1` or - `OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE=1` to disable these caches. -- Set `OPENCLAW_DISABLE_INSTALLED_PLUGIN_MANIFEST_REGISTRY_CACHE=1` to disable - only the installed-index manifest-registry fallback cache. -- Tune cache windows with `OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS` and - `OPENCLAW_PLUGIN_MANIFEST_CACHE_MS`. +Callers that rebuild manifest metadata from the persisted installed plugin +index reconstruct that registry on demand. The installed index is durable +source-plane state; it is not a hidden in-process metadata cache. ## Registry model @@ -944,7 +960,7 @@ source-plane diagnostics without adding a second raw filesystem-path disclosure surface. The persisted `plugins/installs.json` plugin index is the install source of truth and can be refreshed without loading plugin runtime modules. Its `installRecords` map is durable even when a plugin manifest is missing or -invalid; its `plugins` array is a rebuildable manifest/cache view. +invalid; its `plugins` array is a rebuildable manifest view. ## Context engine plugins diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index bfe5ec19ee3..d17a16242f6 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -165,7 +165,9 @@ The snapshot and lookup table keep repeated startup decisions on the fast path: The safety boundary is snapshot replacement, not mutation. Rebuild the snapshot when config, plugin inventory, install records, or persisted index policy changes. Do not treat it as a broad mutable global registry, and do not keep unbounded historical snapshots. Runtime plugin loading remains separate from metadata snapshots so stale runtime state cannot be hidden behind a metadata cache. -Some cold-path callers still reconstruct manifest registries directly from the persisted installed plugin index instead of receiving a Gateway `PluginLookUpTable`. That fallback path keeps a small bounded in-memory cache keyed by the installed index, request shape, config policy, runtime roots, and manifest/package file signatures. It is a fallback safety net for repeated index reconstruction, not the preferred Gateway hot path. Prefer passing the current lookup table or an explicit manifest registry through runtime flows when a caller already has one. +The cache rule is documented in [Plugin architecture internals](/plugins/architecture-internals#plugin-cache-boundary): manifest and discovery metadata are fresh unless a caller holds an explicit snapshot, lookup table, or manifest registry for the current flow. Hidden metadata caches and wall-clock TTLs are not part of plugin loading. Only runtime loader, module, and dependency-artifact caches may persist after code or installed artifacts are actually loaded. + +Some cold-path callers still reconstruct manifest registries directly from the persisted installed plugin index instead of receiving a Gateway `PluginLookUpTable`. That path now reconstructs the registry on demand; prefer passing the current lookup table or an explicit manifest registry through runtime flows when a caller already has one. ### Activation planning diff --git a/scripts/test-built-plugin-singleton.mjs b/scripts/test-built-plugin-singleton.mjs index 6944f340fbe..5c6fd98fe7f 100644 --- a/scripts/test-built-plugin-singleton.mjs +++ b/scripts/test-built-plugin-singleton.mjs @@ -118,7 +118,6 @@ const registry = loadOpenClawPlugins({ env: { ...process.env, OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"), - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", }, config: { plugins: { diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts index e0927d4f1fc..8cf20de148b 100644 --- a/scripts/write-cli-startup-metadata.ts +++ b/scripts/write-cli-startup-metadata.ts @@ -165,10 +165,6 @@ function createIsolatedRootHelpRenderContext( NO_COLOR: "1", OPENCLAW_BUNDLED_PLUGINS_DIR: bundledPluginsDir, OPENCLAW_DISABLE_BUNDLED_PLUGINS: "", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "0", OPENCLAW_STATE_DIR: stateDir, }; const config: OpenClawConfig = { diff --git a/src/agents/live-target-matcher.test.ts b/src/agents/live-target-matcher.test.ts index b99b1ac6a88..70233cb370f 100644 --- a/src/agents/live-target-matcher.test.ts +++ b/src/agents/live-target-matcher.test.ts @@ -11,9 +11,7 @@ vi.mock("./live-provider-owner.js", () => { }); describe("createLiveTargetMatcher", () => { - const env = { - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", - } as NodeJS.ProcessEnv; + const env = {} as NodeJS.ProcessEnv; it("matches Anthropic-owned models for the claude-cli provider filter", () => { const matcher = createLiveTargetMatcher({ diff --git a/src/channels/plugins/bootstrap-registry.ts b/src/channels/plugins/bootstrap-registry.ts index 183a840e4b6..e55f849186c 100644 --- a/src/channels/plugins/bootstrap-registry.ts +++ b/src/channels/plugins/bootstrap-registry.ts @@ -10,15 +10,6 @@ import { import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; -type CachedBootstrapPlugins = { - sortedIds: string[]; - byId: Map; - secretsById: Map; - missingIds: Set; -}; - -const cachedBootstrapPluginsByRoot = new Map(); - function resolveBootstrapChannelId(id: ChannelId): string { return normalizeOptionalString(id) ?? ""; } @@ -68,37 +59,9 @@ function mergeBootstrapPlugin( } as ChannelPlugin; } -function buildBootstrapPlugins( - cacheKey: string, - env: NodeJS.ProcessEnv = process.env, -): CachedBootstrapPlugins { - return { - sortedIds: listBundledChannelPluginIdsForRoot(cacheKey, env), - byId: new Map(), - secretsById: new Map(), - missingIds: new Set(), - }; -} - -function getBootstrapPlugins( - cacheKey = resolveBundledChannelRootScope().cacheKey, - env: NodeJS.ProcessEnv = process.env, -): CachedBootstrapPlugins { - const cached = cachedBootstrapPluginsByRoot.get(cacheKey); - if (cached) { - return cached; - } - const created = buildBootstrapPlugins(cacheKey, env); - cachedBootstrapPluginsByRoot.set(cacheKey, created); - return created; -} - -function resolveActiveBootstrapPlugins(): CachedBootstrapPlugins { - return getBootstrapPlugins(resolveBundledChannelRootScope().cacheKey); -} - export function listBootstrapChannelPluginIds(): readonly string[] { - return resolveActiveBootstrapPlugins().sortedIds; + const rootScope = resolveBundledChannelRootScope(); + return listBundledChannelPluginIdsForRoot(rootScope.cacheKey); } export function* iterateBootstrapChannelPlugins(): IterableIterator { @@ -119,32 +82,18 @@ export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefi if (!resolvedId) { return undefined; } - const registry = resolveActiveBootstrapPlugins(); - const cached = registry.byId.get(resolvedId); - if (cached) { - return cached; - } - if (registry.missingIds.has(resolvedId)) { - return undefined; - } let runtimePlugin: ChannelPlugin | undefined; let setupPlugin: ChannelPlugin | undefined; try { runtimePlugin = getBundledChannelPlugin(resolvedId); setupPlugin = getBundledChannelSetupPlugin(resolvedId); } catch { - registry.missingIds.add(resolvedId); return undefined; } const merged = runtimePlugin && setupPlugin ? mergeBootstrapPlugin(runtimePlugin, setupPlugin) : (setupPlugin ?? runtimePlugin); - if (!merged) { - registry.missingIds.add(resolvedId); - return undefined; - } - registry.byId.set(resolvedId, merged); return merged; } @@ -153,31 +102,13 @@ export function getBootstrapChannelSecrets(id: ChannelId): ChannelPlugin["secret if (!resolvedId) { return undefined; } - const registry = resolveActiveBootstrapPlugins(); - const cached = registry.secretsById.get(resolvedId); - if (cached) { - return cached; - } - if (registry.secretsById.has(resolvedId)) { - return undefined; - } - if (registry.missingIds.has(resolvedId)) { - registry.secretsById.set(resolvedId, null); - return undefined; - } try { const runtimeSecrets = getBundledChannelSecrets(resolvedId); const setupSecrets = getBundledChannelSetupSecrets(resolvedId); - const merged = mergePluginSection(runtimeSecrets, setupSecrets); - registry.secretsById.set(resolvedId, merged ?? null); - return merged; + return mergePluginSection(runtimeSecrets, setupSecrets); } catch { - registry.missingIds.add(resolvedId); - registry.secretsById.set(resolvedId, null); return undefined; } } -export function clearBootstrapChannelPluginCache(): void { - cachedBootstrapPluginsByRoot.clear(); -} +export function clearBootstrapChannelPluginCache(): void {} diff --git a/src/channels/plugins/bundled-ids.ts b/src/channels/plugins/bundled-ids.ts index f801856179b..0353ae8ea64 100644 --- a/src/channels/plugins/bundled-ids.ts +++ b/src/channels/plugins/bundled-ids.ts @@ -1,21 +1,13 @@ import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js"; import { resolveBundledChannelRootScope } from "./bundled-root.js"; -const bundledChannelPluginIdsByRoot = new Map(); - export function listBundledChannelPluginIdsForRoot( - packageRoot: string, + _packageRoot: string, env: NodeJS.ProcessEnv = process.env, ): string[] { - const cached = bundledChannelPluginIdsByRoot.get(packageRoot); - if (cached) { - return [...cached]; - } - const loaded = listChannelCatalogEntries({ origin: "bundled", env }) + return listChannelCatalogEntries({ origin: "bundled", env }) .map((entry) => entry.pluginId) .toSorted((left, right) => left.localeCompare(right)); - bundledChannelPluginIdsByRoot.set(packageRoot, loaded); - return [...loaded]; } export function listBundledChannelPluginIds(): string[] { diff --git a/src/channels/plugins/bundled-root-caches.test.ts b/src/channels/plugins/bundled-root-caches.test.ts index 38cb69b460f..71e2340c325 100644 --- a/src/channels/plugins/bundled-root-caches.test.ts +++ b/src/channels/plugins/bundled-root-caches.test.ts @@ -53,8 +53,8 @@ afterEach(() => { vi.doUnmock("./bundled-ids.js"); }); -describe("bundled root-aware caches", () => { - it("partitions bundled channel ids by active bundled root without re-importing", async () => { +describe("bundled root-aware plugin lookups", () => { + it("reads bundled channel ids from the active bundled root without re-importing", async () => { const rootA = makeBundledRoot("openclaw-bundled-ids-a-"); const rootB = makeBundledRoot("openclaw-bundled-ids-b-"); @@ -83,16 +83,16 @@ describe("bundled root-aware caches", () => { expect(bundledIds.listBundledChannelPluginIds()).toEqual(["beta"]); }); - it("partitions bootstrap plugin caches by active bundled root without re-importing", async () => { + it("reads bootstrap plugins from the active bundled root without re-importing", async () => { const rootA = makeBundledRoot("openclaw-bootstrap-a-"); const rootB = makeBundledRoot("openclaw-bootstrap-b-"); vi.doMock("./bundled-ids.js", () => ({ - listBundledChannelPluginIdsForRoot: (cacheKey: string) => { - if (cacheKey === rootA.pluginsDir) { + listBundledChannelPluginIdsForRoot: () => { + if (process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === rootA.pluginsDir) { return ["alpha"]; } - if (cacheKey === rootB.pluginsDir) { + if (process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === rootB.pluginsDir) { return ["beta"]; } return []; @@ -154,12 +154,12 @@ describe("bundled root-aware caches", () => { ).toBe("setup-beta-B"); }); - it("marks bundled plugin ids missing when bootstrap plugin loading throws", async () => { + it("retries bootstrap plugin loading after an error", async () => { const root = makeBundledRoot("openclaw-bootstrap-plugin-throw-"); vi.doMock("./bundled-ids.js", () => ({ - listBundledChannelPluginIdsForRoot: (cacheKey: string) => - cacheKey === root.pluginsDir ? ["alpha"] : [], + listBundledChannelPluginIdsForRoot: () => + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === root.pluginsDir ? ["alpha"] : [], })); const getBundledChannelPluginMock = vi.fn(() => { @@ -186,16 +186,16 @@ describe("bundled root-aware caches", () => { expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined(); expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined(); expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined(); - expect(getBundledChannelPluginMock).toHaveBeenCalledTimes(1); - expect(getBundledChannelSecretsMock).not.toHaveBeenCalled(); + expect(getBundledChannelPluginMock).toHaveBeenCalledTimes(2); + expect(getBundledChannelSecretsMock).toHaveBeenCalledTimes(1); }); - it("marks bundled plugin ids missing when bootstrap secrets loading throws", async () => { + it("keeps plugin loading independent from bootstrap secrets loading errors", async () => { const root = makeBundledRoot("openclaw-bootstrap-secrets-throw-"); vi.doMock("./bundled-ids.js", () => ({ - listBundledChannelPluginIdsForRoot: (cacheKey: string) => - cacheKey === root.pluginsDir ? ["alpha"] : [], + listBundledChannelPluginIdsForRoot: () => + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR === root.pluginsDir ? ["alpha"] : [], })); const getBundledChannelSecretsMock = vi.fn(() => { @@ -223,8 +223,11 @@ describe("bundled root-aware caches", () => { process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = root.pluginsDir; expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined(); expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined(); - expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined(); - expect(getBundledChannelSecretsMock).toHaveBeenCalledTimes(1); - expect(getBundledChannelPluginMock).not.toHaveBeenCalled(); + expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toMatchObject({ + id: "alpha", + meta: { id: "alpha", label: "Alpha" }, + }); + expect(getBundledChannelSecretsMock).toHaveBeenCalledTimes(2); + expect(getBundledChannelPluginMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 3c840efa2e8..ec367cbeb2a 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -70,7 +70,7 @@ type GeneratedBundledChannelEntry = { entry: BundledChannelEntryRuntimeContract; }; -type BundledChannelCacheContext = { +type BundledChannelLoadContext = { pluginLoadInProgressIds: Set; setupPluginLoadInProgressIds: Set; entryLoadInProgressIds: Set; @@ -289,10 +289,7 @@ function loadGeneratedBundledChannelSetupEntry(params: { } } -const cachedBundledChannelMetadata = new Map(); -const bundledChannelCacheContexts = new Map(); - -function createBundledChannelCacheContext(): BundledChannelCacheContext { +function createBundledChannelLoadContext(): BundledChannelLoadContext { return { pluginLoadInProgressIds: new Set(), setupPluginLoadInProgressIds: new Set(), @@ -308,43 +305,27 @@ function createBundledChannelCacheContext(): BundledChannelCacheContext { }; } -function getBundledChannelCacheContext(cacheKey: string): BundledChannelCacheContext { - const cached = bundledChannelCacheContexts.get(cacheKey); - if (cached) { - return cached; - } - const created = createBundledChannelCacheContext(); - bundledChannelCacheContexts.set(cacheKey, created); - return created; -} - -function resolveActiveBundledChannelCacheScope(): { +function resolveActiveBundledChannelLoadScope(): { rootScope: BundledChannelRootScope; - cacheContext: BundledChannelCacheContext; + loadContext: BundledChannelLoadContext; } { const rootScope = resolveBundledChannelRootScope(); return { rootScope, - cacheContext: getBundledChannelCacheContext(rootScope.cacheKey), + loadContext: createBundledChannelLoadContext(), }; } function listBundledChannelMetadata( rootScope = resolveBundledChannelRootScope(), ): readonly BundledChannelPluginMetadata[] { - const cached = cachedBundledChannelMetadata.get(rootScope.cacheKey); - if (cached) { - return cached; - } const scanDir = resolveBundledChannelScanDir(rootScope); - const loaded = listBundledChannelPluginMetadata({ + return listBundledChannelPluginMetadata({ rootDir: rootScope.packageRoot, ...(scanDir ? { scanDir } : {}), includeChannelConfigs: false, includeSyntheticChannelConfigs: false, }).filter((metadata) => (metadata.manifest.channels?.length ?? 0) > 0); - cachedBundledChannelMetadata.set(rootScope.cacheKey, loaded); - return loaded; } function listBundledChannelPluginIdsForRoot( @@ -450,42 +431,42 @@ function resolveBundledChannelMetadata( function getLazyGeneratedBundledChannelEntryForRoot( id: ChannelId, rootScope: BundledChannelRootScope, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, ): GeneratedBundledChannelEntry | null { - const cached = cacheContext.lazyEntriesById.get(id); - if (cached) { - return cached; + const previous = loadContext.lazyEntriesById.get(id); + if (previous) { + return previous; } - if (cached === null) { + if (previous === null) { return null; } const metadata = resolveBundledChannelMetadata(id, rootScope); if (!metadata) { - cacheContext.lazyEntriesById.set(id, null); + loadContext.lazyEntriesById.set(id, null); return null; } - if (cacheContext.entryLoadInProgressIds.has(id)) { + if (loadContext.entryLoadInProgressIds.has(id)) { return null; } - cacheContext.entryLoadInProgressIds.add(id); + loadContext.entryLoadInProgressIds.add(id); try { const entry = loadGeneratedBundledChannelEntry({ rootScope, metadata, }); - cacheContext.lazyEntriesById.set(id, entry); + loadContext.lazyEntriesById.set(id, entry); if (entry?.entry.id && entry.entry.id !== id) { - cacheContext.lazyEntriesById.set(entry.entry.id, entry); + loadContext.lazyEntriesById.set(entry.entry.id, entry); } return entry; } finally { - cacheContext.entryLoadInProgressIds.delete(id); + loadContext.entryLoadInProgressIds.delete(id); } } -function cacheBundledChannelSetupEntry( +function rememberBundledChannelSetupEntry( metadata: BundledChannelPluginMetadata, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, entry: BundledChannelSetupEntryRuntimeContract | null, requestedId?: ChannelId, ) { @@ -495,60 +476,60 @@ function cacheBundledChannelSetupEntry( ...(requestedId ? [requestedId] : []), ]); for (const id of ids) { - cacheContext.lazySetupEntriesById.set(id, entry); + loadContext.lazySetupEntriesById.set(id, entry); } } function getLazyGeneratedBundledChannelSetupEntryForRoot( id: ChannelId, rootScope: BundledChannelRootScope, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, ): BundledChannelSetupEntryRuntimeContract | null { - if (cacheContext.lazySetupEntriesById.has(id)) { - return cacheContext.lazySetupEntriesById.get(id) ?? null; + if (loadContext.lazySetupEntriesById.has(id)) { + return loadContext.lazySetupEntriesById.get(id) ?? null; } const metadata = resolveBundledChannelMetadata(id, rootScope); if (!metadata) { - cacheContext.lazySetupEntriesById.set(id, null); + loadContext.lazySetupEntriesById.set(id, null); return null; } - if (cacheContext.setupEntryLoadInProgressIds.has(id)) { + if (loadContext.setupEntryLoadInProgressIds.has(id)) { return null; } - cacheContext.setupEntryLoadInProgressIds.add(id); + loadContext.setupEntryLoadInProgressIds.add(id); try { const setupEntry = loadGeneratedBundledChannelSetupEntry({ rootScope, metadata, }); - cacheBundledChannelSetupEntry(metadata, cacheContext, setupEntry, id); + rememberBundledChannelSetupEntry(metadata, loadContext, setupEntry, id); return setupEntry; } finally { - cacheContext.setupEntryLoadInProgressIds.delete(id); + loadContext.setupEntryLoadInProgressIds.delete(id); } } function getBundledChannelPluginForRoot( id: ChannelId, rootScope: BundledChannelRootScope, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, ): ChannelPlugin | undefined { - if (cacheContext.lazyPluginsById.has(id)) { - return cacheContext.lazyPluginsById.get(id) ?? undefined; + if (loadContext.lazyPluginsById.has(id)) { + return loadContext.lazyPluginsById.get(id) ?? undefined; } - if (cacheContext.pluginLoadInProgressIds.has(id)) { + if (loadContext.pluginLoadInProgressIds.has(id)) { return undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; if (!entry) { return undefined; } - cacheContext.pluginLoadInProgressIds.add(id); + loadContext.pluginLoadInProgressIds.add(id); try { const metadata = resolveBundledChannelMetadata(id, rootScope); const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; if (!plugin) { - cacheContext.lazyPluginsById.set(id, null); + loadContext.lazyPluginsById.set(id, null); return undefined; } const normalizedPlugin = { @@ -559,40 +540,40 @@ function getBundledChannelPluginForRoot( existing: metadata?.packageManifest?.channel, }), }; - cacheContext.lazyPluginsById.set(id, normalizedPlugin); + loadContext.lazyPluginsById.set(id, normalizedPlugin); return normalizedPlugin; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load bundled channel ${id}: ${detail}`); - cacheContext.lazyPluginsById.set(id, null); + loadContext.lazyPluginsById.set(id, null); return undefined; } finally { - cacheContext.pluginLoadInProgressIds.delete(id); + loadContext.pluginLoadInProgressIds.delete(id); } } function getBundledChannelSecretsForRoot( id: ChannelId, rootScope: BundledChannelRootScope, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, ): ChannelPlugin["secrets"] | undefined { - if (cacheContext.lazySecretsById.has(id)) { - return cacheContext.lazySecretsById.get(id) ?? undefined; + if (loadContext.lazySecretsById.has(id)) { + return loadContext.lazySecretsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; if (!entry) { return undefined; } try { const secrets = entry.loadChannelSecrets?.() ?? - getBundledChannelPluginForRoot(id, rootScope, cacheContext)?.secrets; - cacheContext.lazySecretsById.set(id, secrets ?? null); + getBundledChannelPluginForRoot(id, rootScope, loadContext)?.secrets; + loadContext.lazySecretsById.set(id, secrets ?? null); return secrets; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load bundled channel secrets ${id}: ${detail}`); - cacheContext.lazySecretsById.set(id, null); + loadContext.lazySecretsById.set(id, null); return undefined; } } @@ -600,24 +581,24 @@ function getBundledChannelSecretsForRoot( function getBundledChannelAccountInspectorForRoot( id: ChannelId, rootScope: BundledChannelRootScope, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, ): NonNullable | undefined { - if (cacheContext.lazyAccountInspectorsById.has(id)) { - return cacheContext.lazyAccountInspectorsById.get(id) ?? undefined; + if (loadContext.lazyAccountInspectorsById.has(id)) { + return loadContext.lazyAccountInspectorsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; if (!entry?.loadChannelAccountInspector) { - cacheContext.lazyAccountInspectorsById.set(id, null); + loadContext.lazyAccountInspectorsById.set(id, null); return undefined; } try { const inspector = entry.loadChannelAccountInspector(); - cacheContext.lazyAccountInspectorsById.set(id, inspector); + loadContext.lazyAccountInspectorsById.set(id, inspector); return inspector; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load bundled channel account inspector ${id}: ${detail}`); - cacheContext.lazyAccountInspectorsById.set(id, null); + loadContext.lazyAccountInspectorsById.set(id, null); return undefined; } } @@ -625,71 +606,71 @@ function getBundledChannelAccountInspectorForRoot( function getBundledChannelSetupPluginForRoot( id: ChannelId, rootScope: BundledChannelRootScope, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, ): ChannelPlugin | undefined { - if (cacheContext.lazySetupPluginsById.has(id)) { - return cacheContext.lazySetupPluginsById.get(id) ?? undefined; + if (loadContext.lazySetupPluginsById.has(id)) { + return loadContext.lazySetupPluginsById.get(id) ?? undefined; } - if (cacheContext.setupPluginLoadInProgressIds.has(id)) { + if (loadContext.setupPluginLoadInProgressIds.has(id)) { return undefined; } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); if (!entry) { return undefined; } - cacheContext.setupPluginLoadInProgressIds.add(id); + loadContext.setupPluginLoadInProgressIds.add(id); try { const plugin = entry.loadSetupPlugin({ installRuntimeDeps: false }); - cacheContext.lazySetupPluginsById.set(id, plugin); + loadContext.lazySetupPluginsById.set(id, plugin); return plugin; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load bundled channel setup ${id}: ${detail}`); - cacheContext.lazySetupPluginsById.set(id, null); + loadContext.lazySetupPluginsById.set(id, null); return undefined; } finally { - cacheContext.setupPluginLoadInProgressIds.delete(id); + loadContext.setupPluginLoadInProgressIds.delete(id); } } function getBundledChannelSetupSecretsForRoot( id: ChannelId, rootScope: BundledChannelRootScope, - cacheContext: BundledChannelCacheContext, + loadContext: BundledChannelLoadContext, ): ChannelPlugin["secrets"] | undefined { - if (cacheContext.lazySetupSecretsById.has(id)) { - return cacheContext.lazySetupSecretsById.get(id) ?? undefined; + if (loadContext.lazySetupSecretsById.has(id)) { + return loadContext.lazySetupSecretsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); if (!entry) { return undefined; } try { const secrets = entry.loadSetupSecrets?.() ?? - getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext)?.secrets; - cacheContext.lazySetupSecretsById.set(id, secrets ?? null); + getBundledChannelSetupPluginForRoot(id, rootScope, loadContext)?.secrets; + loadContext.lazySetupSecretsById.set(id, secrets ?? null); return secrets; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load bundled channel setup secrets ${id}: ${detail}`); - cacheContext.lazySetupSecretsById.set(id, null); + loadContext.lazySetupSecretsById.set(id, null); return undefined; } } export function listBundledChannelPlugins(): readonly ChannelPlugin[] { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelPluginForRoot(id, rootScope, cacheContext); + const plugin = getBundledChannelPluginForRoot(id, rootScope, loadContext); return plugin ? [plugin] : []; }); } export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); return plugin ? [plugin] : []; }); } @@ -698,15 +679,15 @@ export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, options: { config?: OpenClawConfig } = {}, ): readonly ChannelPlugin[] { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForSetupFeature(rootScope, feature, { config: options.config, }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); if (!hasSetupEntryFeature(setupEntry, feature)) { return []; } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); return plugin ? [plugin] : []; }); } @@ -716,11 +697,11 @@ export function listBundledChannelLegacySessionSurfaces( config?: OpenClawConfig; } = {}, ): readonly BundledChannelLegacySessionSurface[] { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces", { config: options.config, }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); const surface = setupEntry?.loadLegacySessionSurface?.({ installRuntimeDeps: false }); if (surface) { return [surface]; @@ -728,7 +709,7 @@ export function listBundledChannelLegacySessionSurfaces( if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { return []; } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); return plugin?.messaging ? [plugin.messaging] : []; }); } @@ -738,11 +719,11 @@ export function listBundledChannelLegacyStateMigrationDetectors( config?: OpenClawConfig; } = {}, ): readonly BundledChannelLegacyStateMigrationDetector[] { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations", { config: options.config, }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); const detector = setupEntry?.loadLegacyStateMigrationDetector?.({ installRuntimeDeps: false }); if (detector) { return [detector]; @@ -750,7 +731,7 @@ export function listBundledChannelLegacyStateMigrationDetectors( if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { return []; } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); return plugin?.lifecycle?.detectLegacyStateMigrations ? [plugin.lifecycle.detectLegacyStateMigrations] : []; @@ -761,36 +742,36 @@ export function hasBundledChannelEntryFeature( id: ChannelId, feature: keyof NonNullable, ): boolean { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry; + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); + const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; return hasChannelEntryFeature(entry, feature); } export function getBundledChannelAccountInspector( id: ChannelId, ): NonNullable | undefined { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelAccountInspectorForRoot(id, rootScope, cacheContext); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); + return getBundledChannelAccountInspectorForRoot(id, rootScope, loadContext); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelPluginForRoot(id, rootScope, cacheContext); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); + return getBundledChannelPluginForRoot(id, rootScope, loadContext); } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelSecretsForRoot(id, rootScope, cacheContext); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); + return getBundledChannelSecretsForRoot(id, rootScope, loadContext); } export function getBundledChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); + return getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); } export function getBundledChannelSetupSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return getBundledChannelSetupSecretsForRoot(id, rootScope, cacheContext); + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); + return getBundledChannelSetupSecretsForRoot(id, rootScope, loadContext); } export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { @@ -802,8 +783,8 @@ export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin { } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { - const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext)?.entry + const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); + const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry .setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); diff --git a/src/channels/plugins/configured-binding-compiler.ts b/src/channels/plugins/configured-binding-compiler.ts index fb919e4876e..5e3a65ed522 100644 --- a/src/channels/plugins/configured-binding-compiler.ts +++ b/src/channels/plugins/configured-binding-compiler.ts @@ -1,9 +1,5 @@ import { listConfiguredBindings } from "../../config/bindings.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { - getActivePluginChannelRegistryVersion, - requireActivePluginChannelRegistry, -} from "../../plugins/runtime.js"; import { pickFirstExistingAgentId } from "../../routing/resolve-route.js"; import { normalizeOptionalLowercaseString, @@ -25,17 +21,6 @@ export type CompiledConfiguredBindingRegistry = { rulesByChannel: Map; }; -type CachedCompiledConfiguredBindingRegistry = { - registryRef: object | null; - registryVersion: number; - registry: CompiledConfiguredBindingRegistry; -}; - -const compiledRegistryCache = new WeakMap< - OpenClawConfig, - CachedCompiledConfiguredBindingRegistry ->(); - function resolveLoadedChannelPlugin(channel: string) { const normalized = normalizeOptionalLowercaseString(channel); if (!normalized) { @@ -180,35 +165,13 @@ function compileConfiguredBindingRegistry(params: { export function resolveCompiledBindingRegistry( cfg: OpenClawConfig, ): CompiledConfiguredBindingRegistry { - const activeRegistry = requireActivePluginChannelRegistry(); - const registryVersion = getActivePluginChannelRegistryVersion(); - const cached = compiledRegistryCache.get(cfg); - if (cached?.registryVersion === registryVersion && cached.registryRef === activeRegistry) { - return cached.registry; - } - - const registry = compileConfiguredBindingRegistry({ - cfg, - }); - compiledRegistryCache.set(cfg, { - registryRef: activeRegistry, - registryVersion, - registry, - }); - return registry; + return compileConfiguredBindingRegistry({ cfg }); } export function primeCompiledBindingRegistry( cfg: OpenClawConfig, ): CompiledConfiguredBindingRegistry { - const activeRegistry = requireActivePluginChannelRegistry(); - const registry = compileConfiguredBindingRegistry({ cfg }); - compiledRegistryCache.set(cfg, { - registryRef: activeRegistry, - registryVersion: getActivePluginChannelRegistryVersion(), - registry, - }); - return registry; + return compileConfiguredBindingRegistry({ cfg }); } export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): { diff --git a/src/channels/plugins/contracts/plugins-core.loader.contract.test.ts b/src/channels/plugins/contracts/plugins-core.loader.contract.test.ts index d505c7e2c90..3203357c6f7 100644 --- a/src/channels/plugins/contracts/plugins-core.loader.contract.test.ts +++ b/src/channels/plugins/contracts/plugins-core.loader.contract.test.ts @@ -120,7 +120,7 @@ describe("channel plugin loader", () => { expectedOutbound: demoOutbound, }, { - name: "refreshes cached plugin values when registry changes", + name: "reads updated plugin values when registry changes", kind: "reload-plugin" as const, firstRegistry: registryWithDemoLoader, secondRegistry: registryWithDemoLoaderV2, @@ -128,7 +128,7 @@ describe("channel plugin loader", () => { secondExpected: demoLoaderPluginV2, }, { - name: "refreshes cached outbound values when registry changes", + name: "reads updated outbound values when registry changes", kind: "reload-outbound" as const, firstRegistry: registryWithDemoLoader, secondRegistry: registryWithDemoLoaderV2, diff --git a/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts b/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts index 2ed5a0f20c7..430b4d7c3d5 100644 --- a/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts +++ b/src/channels/plugins/contracts/test-helpers/channel-catalog-contract.ts @@ -17,8 +17,6 @@ type CatalogEntryMeta = { function createCatalogFixtureEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { ...process.env, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", ...overrides, }; } diff --git a/src/channels/plugins/message-tool-api.test.ts b/src/channels/plugins/message-tool-api.test.ts index a2084930afe..322ccc852fd 100644 --- a/src/channels/plugins/message-tool-api.test.ts +++ b/src/channels/plugins/message-tool-api.test.ts @@ -30,14 +30,12 @@ vi.mock("../../plugins/public-surface-loader.js", () => ({ })); import { - __testing, describeBundledChannelMessageTool, resolveBundledChannelMessageToolDiscoveryAdapter, } from "./message-tool-api.js"; describe("bundled channel message tool fast path", () => { beforeEach(() => { - __testing.clearMessageToolApiCache(); loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); }); diff --git a/src/channels/plugins/message-tool-api.ts b/src/channels/plugins/message-tool-api.ts index b61852981ce..56113fb8330 100644 --- a/src/channels/plugins/message-tool-api.ts +++ b/src/channels/plugins/message-tool-api.ts @@ -12,23 +12,16 @@ type MessageToolApi = { const MESSAGE_TOOL_API_ARTIFACT_BASENAME = "message-tool-api.js"; const MISSING_PUBLIC_SURFACE_PREFIX = "Unable to resolve bundled plugin public surface "; -const messageToolApiCache = new Map(); function loadBundledChannelMessageToolApi(channelId: string): MessageToolApi | undefined { const cacheKey = channelId.trim(); - if (messageToolApiCache.has(cacheKey)) { - return messageToolApiCache.get(cacheKey); - } try { - const loaded = loadBundledPluginPublicArtifactModuleSync({ + return loadBundledPluginPublicArtifactModuleSync({ dirName: cacheKey, artifactBasename: MESSAGE_TOOL_API_ARTIFACT_BASENAME, }); - messageToolApiCache.set(cacheKey, loaded); - return loaded; } catch (error) { if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { - messageToolApiCache.set(cacheKey, undefined); return undefined; } throw error; @@ -57,7 +50,3 @@ export function describeBundledChannelMessageTool(params: { } return describeMessageTool(params.context) ?? null; } - -export const __testing = { - clearMessageToolApiCache: () => messageToolApiCache.clear(), -}; diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index bb9e7ab5b49..8ff20ace6bb 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -24,14 +24,7 @@ type ChannelPackageStateMetadata = { export type ChannelPackageStateMetadataKey = "configuredState" | "persistedAuthState"; -type ChannelPackageStateRegistry = { - catalog: PluginChannelCatalogEntry[]; - entriesById: Map; - checkerCache: Map; -}; - const log = createSubsystemLogger("channels"); -const registryCache = new Map(); function resolveChannelPackageStateMetadata( entry: PluginChannelCatalogEntry, @@ -49,38 +42,20 @@ function resolveChannelPackageStateMetadata( return { specifier, exportName }; } -function getChannelPackageStateRegistry( +function listChannelPackageStateCatalog( metadataKey: ChannelPackageStateMetadataKey, -): ChannelPackageStateRegistry { - const cached = registryCache.get(metadataKey); - if (cached) { - return cached; - } - const catalog = listChannelCatalogEntries({ origin: "bundled" }).filter((entry) => +): PluginChannelCatalogEntry[] { + return listChannelCatalogEntries({ origin: "bundled" }).filter((entry) => Boolean(resolveChannelPackageStateMetadata(entry, metadataKey)), ); - const registry = { - catalog, - entriesById: new Map(catalog.map((entry) => [entry.pluginId, entry] as const)), - checkerCache: new Map(), - } satisfies ChannelPackageStateRegistry; - registryCache.set(metadataKey, registry); - return registry; } function resolveChannelPackageStateChecker(params: { entry: PluginChannelCatalogEntry; metadataKey: ChannelPackageStateMetadataKey; }): ChannelPackageStateChecker | null { - const registry = getChannelPackageStateRegistry(params.metadataKey); - const cached = registry.checkerCache.get(params.entry.pluginId); - if (cached !== undefined) { - return cached; - } - const metadata = resolveChannelPackageStateMetadata(params.entry, params.metadataKey); if (!metadata) { - registry.checkerCache.set(params.entry.pluginId, null); return null; } @@ -94,14 +69,12 @@ function resolveChannelPackageStateChecker(params: { if (typeof checker !== "function") { throw new Error(`missing ${params.metadataKey} export ${metadata.exportName}`); } - registry.checkerCache.set(params.entry.pluginId, checker); return checker; } catch (error) { const detail = formatErrorMessage(error); log.warn( `[channels] failed to load ${params.metadataKey} checker for ${params.entry.pluginId}: ${detail}`, ); - registry.checkerCache.set(params.entry.pluginId, null); return null; } } @@ -109,7 +82,7 @@ function resolveChannelPackageStateChecker(params: { export function listBundledChannelIdsForPackageState( metadataKey: ChannelPackageStateMetadataKey, ): string[] { - return getChannelPackageStateRegistry(metadataKey).catalog.map((entry) => entry.pluginId); + return listChannelPackageStateCatalog(metadataKey).map((entry) => entry.pluginId); } export function hasBundledChannelPackageState(params: { @@ -118,8 +91,9 @@ export function hasBundledChannelPackageState(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; }): boolean { - const registry = getChannelPackageStateRegistry(params.metadataKey); - const entry = registry.entriesById.get(params.channelId); + const entry = listChannelPackageStateCatalog(params.metadataKey).find( + (candidate) => candidate.pluginId === params.channelId, + ); if (!entry) { return false; } diff --git a/src/channels/plugins/registry-loaded.ts b/src/channels/plugins/registry-loaded.ts index 66c3f851436..ea8d65d0603 100644 --- a/src/channels/plugins/registry-loaded.ts +++ b/src/channels/plugins/registry-loaded.ts @@ -2,10 +2,7 @@ import type { ActiveChannelPluginRuntimeShape, ActivePluginChannelRegistration, } from "../../plugins/channel-registry-state.types.js"; -import { - getActivePluginChannelRegistryFromState, - getActivePluginChannelRegistryVersionFromState, -} from "../../plugins/runtime-channel-state.js"; +import { getActivePluginChannelRegistryFromState } from "../../plugins/runtime-channel-state.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { CHAT_CHANNEL_ORDER } from "../registry.js"; @@ -18,24 +15,12 @@ export type LoadedChannelPluginEntry = ActivePluginChannelRegistration & { plugin: LoadedChannelPlugin; }; -type CachedChannelPlugins = { - registryVersion: number; - registryRef: object | null; +type ChannelPluginView = { sorted: LoadedChannelPlugin[]; byId: Map; entriesById: Map; }; -const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = { - registryVersion: -1, - registryRef: null, - sorted: [], - byId: new Map(), - entriesById: new Map(), -}; - -let cachedChannelPlugins = EMPTY_CHANNEL_PLUGIN_CACHE; - function coerceLoadedChannelPlugin( plugin: ActiveChannelPluginRuntimeShape | null | undefined, ): LoadedChannelPlugin | null { @@ -63,13 +48,8 @@ function dedupeChannels(channels: LoadedChannelPlugin[]): LoadedChannelPlugin[] return resolved; } -function resolveCachedChannelPlugins(): CachedChannelPlugins { +function resolveChannelPlugins(): ChannelPluginView { const registry = getActivePluginChannelRegistryFromState(); - const registryVersion = getActivePluginChannelRegistryVersionFromState(); - const cached = cachedChannelPlugins; - if (cached.registryVersion === registryVersion && cached.registryRef === registry) { - return cached; - } const channelPlugins: LoadedChannelPlugin[] = []; const pluginEntries: LoadedChannelPluginEntry[] = []; @@ -104,19 +84,15 @@ function resolveCachedChannelPlugins(): CachedChannelPlugins { } } - const next: CachedChannelPlugins = { - registryVersion, - registryRef: registry, + return { sorted, byId, entriesById, }; - cachedChannelPlugins = next; - return next; } export function listLoadedChannelPlugins(): LoadedChannelPlugin[] { - return resolveCachedChannelPlugins().sorted.slice(); + return resolveChannelPlugins().sorted.slice(); } export function getLoadedChannelPluginById(id: string): LoadedChannelPlugin | undefined { @@ -124,7 +100,7 @@ export function getLoadedChannelPluginById(id: string): LoadedChannelPlugin | un if (!resolvedId) { return undefined; } - return resolveCachedChannelPlugins().byId.get(resolvedId); + return resolveChannelPlugins().byId.get(resolvedId); } export function getLoadedChannelPluginEntryById(id: string): LoadedChannelPluginEntry | undefined { @@ -132,5 +108,5 @@ export function getLoadedChannelPluginEntryById(id: string): LoadedChannelPlugin if (!resolvedId) { return undefined; } - return resolveCachedChannelPlugins().entriesById.get(resolvedId); + return resolveChannelPlugins().entriesById.get(resolvedId); } diff --git a/src/channels/plugins/registry-loader.ts b/src/channels/plugins/registry-loader.ts index fac618fad73..aad472a5b4b 100644 --- a/src/channels/plugins/registry-loader.ts +++ b/src/channels/plugins/registry-loader.ts @@ -1,4 +1,4 @@ -import type { PluginChannelRegistration, PluginRegistry } from "../../plugins/registry-types.js"; +import type { PluginChannelRegistration } from "../../plugins/registry-types.js"; import { getActivePluginChannelRegistry } from "../../plugins/runtime.js"; import type { ChannelId } from "./channel-id.types.js"; @@ -9,27 +9,12 @@ type ChannelRegistryValueResolver = ( export function createChannelRegistryLoader( resolveValue: ChannelRegistryValueResolver, ): (id: ChannelId) => Promise { - const cache = new Map(); - let lastRegistry: PluginRegistry | null = null; - return async (id: ChannelId): Promise => { const registry = getActivePluginChannelRegistry(); - if (registry !== lastRegistry) { - cache.clear(); - lastRegistry = registry; - } - const cached = cache.get(id); - if (cached) { - return cached; - } const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id); if (!pluginEntry) { return undefined; } - const resolved = resolveValue(pluginEntry); - if (resolved) { - cache.set(id, resolved); - } - return resolved; + return resolveValue(pluginEntry); }; } diff --git a/src/channels/plugins/session-conversation.bundled-fallback.test.ts b/src/channels/plugins/session-conversation.bundled-fallback.test.ts index 730436921bd..0bad3e1d86b 100644 --- a/src/channels/plugins/session-conversation.bundled-fallback.test.ts +++ b/src/channels/plugins/session-conversation.bundled-fallback.test.ts @@ -138,7 +138,7 @@ describe("session conversation bundled fallback", () => { }); }); - it("reuses the bundled fallback loader result across repeated calls", () => { + it("delegates repeated fallback calls through the public-surface loader", () => { enableThreadedFallback(); expect(resolveSessionConversationRef("agent:main:mock-threaded:group:room:topic:42")).toEqual( @@ -155,6 +155,6 @@ describe("session conversation bundled fallback", () => { threadId: "43", }), ); - expect(fallbackState.loadCalls).toBe(1); + expect(fallbackState.loadCalls).toBe(2); }); }); diff --git a/src/channels/plugins/session-conversation.ts b/src/channels/plugins/session-conversation.ts index d3dcf3f5cc8..f7a37e075d6 100644 --- a/src/channels/plugins/session-conversation.ts +++ b/src/channels/plugins/session-conversation.ts @@ -1,6 +1,5 @@ import { getRuntimeConfigSnapshot } from "../../config/runtime-snapshot.js"; import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js"; -import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js"; import { parseRawSessionConversationRef, parseThreadSessionSuffix, @@ -59,16 +58,6 @@ type NormalizedSessionConversationResolution = ResolvedSessionConversation & { hasExplicitParentConversationCandidates: boolean; }; -type BundledSessionConversationFallbackCacheEntry = { - version: number; - resolveSessionConversation: BundledSessionKeyModule["resolveSessionConversation"] | null; -}; - -const bundledSessionConversationFallbackCache = new Map< - string, - BundledSessionConversationFallbackCacheEntry ->(); - function normalizeResolvedChannel(channel: string): string { return ( normalizeAnyChannelId(channel) ?? @@ -159,35 +148,22 @@ function resolveBundledSessionConversationFallback(params: { return null; } const dirName = normalizeResolvedChannel(params.channel); - const version = getActivePluginChannelRegistryVersion(); - let cached = bundledSessionConversationFallbackCache.get(dirName); - if (!cached || cached.version !== version) { - let resolveSessionConversation: BundledSessionKeyModule["resolveSessionConversation"] | null = - null; - try { - const loaded = tryLoadActivatedBundledPluginPublicSurfaceModuleSync({ - dirName, - artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME, - }); - resolveSessionConversation = - typeof loaded?.resolveSessionConversation === "function" - ? loaded.resolveSessionConversation - : null; - } catch { - resolveSessionConversation = null; - } - cached = { - version, - resolveSessionConversation, - }; - bundledSessionConversationFallbackCache.set(dirName, cached); + let loaded: BundledSessionKeyModule | null = null; + try { + loaded = tryLoadActivatedBundledPluginPublicSurfaceModuleSync({ + dirName, + artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME, + }); + } catch { + return null; } - if (typeof cached.resolveSessionConversation !== "function") { + const resolveSessionConversation = loaded?.resolveSessionConversation; + if (typeof resolveSessionConversation !== "function") { return null; } return normalizeSessionConversationResolution( - cached.resolveSessionConversation({ + resolveSessionConversation({ kind: params.kind, rawId: params.rawId, }), diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts index 3e7269a5a32..db19cf77fc3 100644 --- a/src/channels/plugins/setup-registry.ts +++ b/src/channels/plugins/setup-registry.ts @@ -1,6 +1,5 @@ import { getActivePluginChannelRegistry, - getActivePluginRegistryVersion, requireActivePluginRegistry, } from "../../plugins/runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -9,22 +8,11 @@ import { listBundledChannelSetupPlugins } from "./bundled.js"; import type { ChannelPlugin } from "./types.plugin.js"; import type { ChannelId } from "./types.public.js"; -type CachedChannelSetupPlugins = { - registryVersion: number; - registryRef: object | null; +type ChannelSetupPluginView = { sorted: ChannelPlugin[]; byId: Map; }; -const EMPTY_CHANNEL_SETUP_CACHE: CachedChannelSetupPlugins = { - registryVersion: -1, - registryRef: null, - sorted: [], - byId: new Map(), -}; - -let cachedChannelSetupPlugins = EMPTY_CHANNEL_SETUP_CACHE; - function dedupeSetupPlugins(plugins: readonly ChannelPlugin[]): ChannelPlugin[] { const seen = new Set(); const resolved: ChannelPlugin[] = []; @@ -52,13 +40,8 @@ function sortChannelSetupPlugins(plugins: readonly ChannelPlugin[]): ChannelPlug }); } -function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { +function resolveChannelSetupPlugins(): ChannelSetupPluginView { const registry = requireActivePluginRegistry(); - const registryVersion = getActivePluginRegistryVersion(); - const cached = cachedChannelSetupPlugins; - if (cached.registryVersion === registryVersion && cached.registryRef === registry) { - return cached; - } const registryPlugins = (registry.channelSetups ?? []).map((entry) => entry.plugin); const sorted = sortChannelSetupPlugins( @@ -69,18 +52,14 @@ function resolveCachedChannelSetupPlugins(): CachedChannelSetupPlugins { byId.set(plugin.id, plugin); } - const next: CachedChannelSetupPlugins = { - registryVersion, - registryRef: registry, + return { sorted, byId, }; - cachedChannelSetupPlugins = next; - return next; } export function listChannelSetupPlugins(): ChannelPlugin[] { - return resolveCachedChannelSetupPlugins().sorted.slice(); + return resolveChannelSetupPlugins().sorted.slice(); } export function listActiveChannelSetupPlugins(): ChannelPlugin[] { @@ -93,5 +72,5 @@ export function getChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined if (!resolvedId) { return undefined; } - return resolveCachedChannelSetupPlugins().byId.get(resolvedId); + return resolveChannelSetupPlugins().byId.get(resolvedId); } diff --git a/src/channels/plugins/thread-binding-api.test.ts b/src/channels/plugins/thread-binding-api.test.ts index f2d39b1de71..b1a96900ecb 100644 --- a/src/channels/plugins/thread-binding-api.test.ts +++ b/src/channels/plugins/thread-binding-api.test.ts @@ -35,14 +35,12 @@ vi.mock("../../plugins/public-surface-loader.js", () => ({ })); import { - __testing, resolveBundledChannelThreadBindingDefaultPlacement, resolveBundledChannelThreadBindingInboundConversation, } from "./thread-binding-api.js"; describe("bundled channel thread binding fast path", () => { beforeEach(() => { - __testing.clearThreadBindingApiCache(); loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); }); diff --git a/src/channels/plugins/thread-binding-api.ts b/src/channels/plugins/thread-binding-api.ts index 11d1dbba45f..7d3a4628f26 100644 --- a/src/channels/plugins/thread-binding-api.ts +++ b/src/channels/plugins/thread-binding-api.ts @@ -25,23 +25,16 @@ type ThreadBindingApi = { const THREAD_BINDING_API_ARTIFACT_BASENAME = "thread-binding-api.js"; const MISSING_PUBLIC_SURFACE_PREFIX = "Unable to resolve bundled plugin public surface "; -const threadBindingApiCache = new Map(); function loadBundledChannelThreadBindingApi(channelId: string): ThreadBindingApi | undefined { const cacheKey = channelId.trim(); - if (threadBindingApiCache.has(cacheKey)) { - return threadBindingApiCache.get(cacheKey); - } try { - const loaded = loadBundledPluginPublicArtifactModuleSync({ + return loadBundledPluginPublicArtifactModuleSync({ dirName: cacheKey, artifactBasename: THREAD_BINDING_API_ARTIFACT_BASENAME, }); - threadBindingApiCache.set(cacheKey, loaded); - return loaded; } catch (error) { if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { - threadBindingApiCache.set(cacheKey, undefined); return undefined; } throw error; @@ -76,7 +69,3 @@ export function resolveBundledChannelThreadBindingInboundConversation( isGroup: params.isGroup, }); } - -export const __testing = { - clearThreadBindingApiCache: () => threadBindingApiCache.clear(), -}; diff --git a/src/commands/auth-choice-legacy.test.ts b/src/commands/auth-choice-legacy.test.ts index e4df5332326..886b679aacb 100644 --- a/src/commands/auth-choice-legacy.test.ts +++ b/src/commands/auth-choice-legacy.test.ts @@ -37,8 +37,6 @@ function authChoiceManifestEnv(): NodeJS.ProcessEnv { OPENCLAW_BUNDLED_PLUGINS_DIR: "extensions", OPENCLAW_DISABLE_BUNDLED_PLUGINS: "0", OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", VITEST: "1", } as NodeJS.ProcessEnv; } diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index 5082fff2237..b3142817d5b 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -99,11 +99,6 @@ vi.mock("../commands/onboarding-plugin-install.js", () => ({ ensureOnboardingPluginInstalled, })); -const clearPluginDiscoveryCache = vi.hoisted(() => vi.fn()); -vi.mock("../plugins/discovery.js", () => ({ - clearPluginDiscoveryCache, -})); - const LOCAL_PROVIDER_ID = "local-provider"; const LOCAL_PROVIDER_LABEL = "Local Provider"; const LOCAL_AUTH_METHOD_ID = "local"; @@ -388,7 +383,6 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { workspaceDir: "/tmp/workspace", }), ); - expect(clearPluginDiscoveryCache).toHaveBeenCalledOnce(); expect(resolvePluginProviders).toHaveBeenCalledTimes(2); expect(result?.config.agents?.defaults?.model).toEqual({ primary: LOCAL_DEFAULT_MODEL, @@ -412,7 +406,6 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); - expect(clearPluginDiscoveryCache).toHaveBeenCalledOnce(); expect(result).toEqual({ config: { plugins: { diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 5662a9aacfd..accaa79ae59 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -85,10 +85,8 @@ vi.mock("../../plugins/loader.js", () => ({ loadOpenClawPlugins: vi.fn(), })); -const clearPluginDiscoveryCache = vi.fn(); const discoverOpenClawPlugins = vi.fn((_args?: unknown) => ({ candidates: [], diagnostics: [] })); vi.mock("../../plugins/discovery.js", () => ({ - clearPluginDiscoveryCache: () => clearPluginDiscoveryCache(), discoverOpenClawPlugins: (args: unknown) => discoverOpenClawPlugins(args), })); @@ -598,7 +596,7 @@ describe("ensureChannelSetupPluginInstalled", () => { expect(result.pluginId).toBe("wecom"); }); - it("clears discovery cache before reloading the setup plugin registry", () => { + it("reloads the setup plugin registry without using plugin registry cache", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; @@ -608,7 +606,6 @@ describe("ensureChannelSetupPluginInstalled", () => { workspaceDir: "/tmp/openclaw-workspace", }); - expect(clearPluginDiscoveryCache).toHaveBeenCalledTimes(1); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ config: cfg, @@ -619,9 +616,6 @@ describe("ensureChannelSetupPluginInstalled", () => { includeSetupOnlyChannelPlugins: true, }), ); - expect(clearPluginDiscoveryCache.mock.invocationCallOrder[0]).toBeLessThan( - vi.mocked(loadOpenClawPlugins).mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); }); it("loads the setup plugin registry from the auto-enabled config snapshot", () => { diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index fd34b6d15da..01966c1bdf8 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -4,7 +4,6 @@ import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveDiscoverableScopedChannelPluginIds } from "../../plugins/channel-plugin-ids.js"; -import { clearPluginDiscoveryCache } from "../../plugins/discovery.js"; import { loadOpenClawPlugins } from "../../plugins/loader.js"; import { createPluginLoaderLogger } from "../../plugins/logger.js"; import type { PluginRegistry } from "../../plugins/registry.js"; @@ -80,7 +79,6 @@ function loadChannelSetupPluginRegistry(params: { installRuntimeDeps?: boolean; forceSetupOnlyChannelPlugins?: boolean; }): PluginRegistry { - clearPluginDiscoveryCache(); const autoEnabled = applyPluginAutoEnable({ config: params.cfg, env: process.env }); const resolvedConfig = autoEnabled.config; const workspaceDir = diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 653e26f2d57..319e63b2c65 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -268,7 +268,7 @@ export async function channelsAddCommand( let catalogEntry = channel ? undefined : await resolveCatalogChannelEntry(rawChannel, nextConfig); const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); - // May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import) + // May load a scoped plugin when the channel is not already registered. const loadScopedPlugin = async ( channelId: ChannelId, pluginId?: string, diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index 3822e92e522..d6ed337bc72 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -30,8 +30,6 @@ function makeTempDir() { function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", ...overrides, diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.ts b/src/commands/doctor/shared/legacy-web-search-migrate.ts index 854464d65e4..4b4a22ea110 100644 --- a/src/commands/doctor/shared/legacy-web-search-migrate.ts +++ b/src/commands/doctor/shared/legacy-web-search-migrate.ts @@ -18,23 +18,19 @@ const MODERN_SCOPED_WEB_SEARCH_KEYS = new Set(["openaiCodex"]); // `tools.web.search.tavily.*` shape to migrate. const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set(["tavily"]); const LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID = "brave"; -let legacyWebSearchProviderIdsCache: string[] | undefined; -let legacyWebSearchProviderIdSetCache: Set | undefined; function getLegacyWebSearchProviderIds(): string[] { - legacyWebSearchProviderIdsCache ??= loadPluginManifestRegistryForPluginRegistry({ + return loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true, }) .plugins.filter((plugin) => plugin.origin === "bundled") .flatMap((plugin) => plugin.contracts?.webSearchProviders ?? []) .filter((providerId) => !NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS.has(providerId)) .toSorted((left, right) => left.localeCompare(right)); - return legacyWebSearchProviderIdsCache; } function getLegacyWebSearchProviderIdSet(): Set { - legacyWebSearchProviderIdSetCache ??= new Set(getLegacyWebSearchProviderIds()); - return legacyWebSearchProviderIdSetCache; + return new Set(getLegacyWebSearchProviderIds()); } function resolveLegacySearchConfig(raw: unknown): JsonRecord | undefined { diff --git a/src/commands/doctor/shared/plugin-registry-migration.test.ts b/src/commands/doctor/shared/plugin-registry-migration.test.ts index ebeb58920f4..f1a25a18515 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.test.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.test.ts @@ -31,8 +31,6 @@ function makeTempDir() { function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", ...overrides, diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index accf03b6aec..7d25f1913bc 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -107,8 +107,6 @@ describe("resolveNativeSkillsEnabled", () => { ...process.env, OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", }; expect( diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 4318a1deb79..ddc739a65f9 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; vi.unmock("../version.js"); @@ -108,7 +107,6 @@ describe("config plugin validation", () => { HOME: suiteHome, OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: path.join(suiteHome, ".openclaw"), - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "10000", OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, OPENCLAW_VERSION: undefined, VITEST: "true", @@ -208,28 +206,10 @@ describe("config plugin validation", () => { id: "voice-call-schema-fixture", schema: voiceCallManifest.configSchema, }); - clearPluginManifestRegistryCache(); - // Warm the plugin manifest cache once so path-based validations can reuse - // parsed manifests across test cases. - validateInSuite({ - plugins: { - enabled: false, - load: { - paths: [ - badPluginDir, - bluebubblesPluginDir, - bundlePluginDir, - manifestlessClaudeBundleDir, - voiceCallSchemaPluginDir, - ], - }, - }, - }); }); afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); - clearPluginManifestRegistryCache(); }); it("reports missing plugin refs across entries and allowlist surfaces", async () => { diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index 0472701e1cb..20ca08317f8 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -20,12 +20,8 @@ export async function withTempHome(fn: (home: string) => Promise): Promise OPENCLAW_CONFIG_PATH: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: undefined, - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: undefined, OPENCLAW_PLUGIN_CATALOG_PATHS: undefined, OPENCLAW_MPM_CATALOG_PATHS: undefined, - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: undefined, - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: undefined, OPENCLAW_LOAD_SHELL_ENV: undefined, OPENCLAW_DEFER_SHELL_ENV_FALLBACK: undefined, OPENCLAW_SHELL_ENV_TIMEOUT_MS: undefined, diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 622638fe500..77f8fb9646c 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -17,7 +17,6 @@ const ChannelModelByChannelSchema = z .record(z.string(), z.record(z.string(), z.string())) .optional(); -let directChannelRuntimeSchemasCache: ReadonlyMap | undefined; const OPENCLAW_PACKAGE_ROOT = resolveLoaderPackageRoot({ modulePath: fileURLToPath(import.meta.url), @@ -25,25 +24,12 @@ const OPENCLAW_PACKAGE_ROOT = }) ?? fileURLToPath(new URL("../..", import.meta.url)); function getDirectChannelRuntimeSchema(channelId: string): ChannelConfigRuntimeSchema | undefined { - if (!directChannelRuntimeSchemasCache) { - directChannelRuntimeSchemasCache = new Map(); - } - - const cached = directChannelRuntimeSchemasCache.get(channelId); - if (cached) { - return cached; - } - for (const entry of listBundledPluginMetadata({ includeChannelConfigs: false, includeSyntheticChannelConfigs: false, })) { const manifestRuntime = entry.manifest.channelConfigs?.[channelId]?.runtime; if (manifestRuntime) { - (directChannelRuntimeSchemasCache as Map).set( - channelId, - manifestRuntime, - ); return manifestRuntime; } if (!entry.manifest.channels?.includes(channelId)) { @@ -56,10 +42,6 @@ function getDirectChannelRuntimeSchema(channelId: string): ChannelConfigRuntimeS }); const collectedRuntime = collectedChannelConfigs?.[channelId]?.runtime; if (collectedRuntime) { - (directChannelRuntimeSchemasCache as Map).set( - channelId, - collectedRuntime, - ); return collectedRuntime; } } diff --git a/src/plugin-sdk/qa-runner-runtime.integration.test.ts b/src/plugin-sdk/qa-runner-runtime.integration.test.ts index 411769f30c8..c7173ed7990 100644 --- a/src/plugin-sdk/qa-runner-runtime.integration.test.ts +++ b/src/plugin-sdk/qa-runner-runtime.integration.test.ts @@ -2,17 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; -import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { resetFacadeRuntimeStateForTest } from "./facade-runtime.js"; const ORIGINAL_ENV = { OPENCLAW_DISABLE_BUNDLED_PLUGINS: process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS, OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE, - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: process.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE, - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: process.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, OPENCLAW_TEST_FAST: process.env.OPENCLAW_TEST_FAST, } as const; @@ -25,8 +19,6 @@ function makeTempDir(prefix: string): string { } function resetQaRunnerRuntimeState() { - clearPluginDiscoveryCache(); - clearPluginManifestRegistryCache(); resetFacadeRuntimeStateForTest(); } @@ -34,10 +26,6 @@ describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => { beforeEach(() => { resetQaRunnerRuntimeState(); process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1"; - process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE = "1"; - process.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE = "1"; - process.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "0"; - process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS = "0"; process.env.OPENCLAW_TEST_FAST = "1"; }); diff --git a/src/plugins/AGENTS.md b/src/plugins/AGENTS.md index 0eba1f65b4a..ce08e832e51 100644 --- a/src/plugins/AGENTS.md +++ b/src/plugins/AGENTS.md @@ -27,6 +27,13 @@ assembly, and contract enforcement. belongs to runtime resolution. - Preserve manifest-first behavior: discovery, config validation, and setup should work from metadata before plugin runtime executes. +- Cache concept: metadata stays fresh unless a caller owns an explicit + `PluginMetadataSnapshot`, `PluginLookUpTable`, or manifest registry for the + current flow. Do not add persistent metadata caches for discovery, manifest + registries, installed-index reconstruction, owner lookup, model suppression, + provider policy, public-artifact metadata, or similar control-plane answers. + Runtime loader, jiti/module, and dependency-artifact caches are the allowed + cache layer once code or installed artifacts are actually loaded. - Keep loader behavior aligned with the documented Plugin SDK and manifest contracts. Do not create private backdoors that bundled plugins can use but external plugins cannot. diff --git a/src/plugins/bundled-package-channel-metadata.test.ts b/src/plugins/bundled-package-channel-metadata.test.ts index 0083b8e4014..1fc269366cc 100644 --- a/src/plugins/bundled-package-channel-metadata.test.ts +++ b/src/plugins/bundled-package-channel-metadata.test.ts @@ -48,4 +48,34 @@ describe("bundled package channel metadata", () => { warnOnEmptyGroupSenderAllowlist: true, }); }); + + it("reflects package channel metadata edits on the next read", () => { + const root = makeTempRepoRoot(tempDirs, "bpcm-fresh-"); + const extensionsRoot = path.join(root, "dist", "extensions"); + const packagePath = path.join(extensionsRoot, "matrix", "package.json"); + vi.mocked(resolveBundledPluginsDir).mockReturnValue(extensionsRoot); + + writeJsonFile(packagePath, { + name: "@openclaw/matrix", + openclaw: { + channel: { + id: "matrix", + label: "Before", + }, + }, + }); + expect(findBundledPackageChannelMetadata("matrix")?.label).toBe("Before"); + + writeJsonFile(packagePath, { + name: "@openclaw/matrix", + openclaw: { + channel: { + id: "matrix", + label: "After", + }, + }, + }); + + expect(findBundledPackageChannelMetadata("matrix")?.label).toBe("After"); + }); }); diff --git a/src/plugins/bundled-package-channel-metadata.ts b/src/plugins/bundled-package-channel-metadata.ts index b2d108059f7..fcb91532330 100644 --- a/src/plugins/bundled-package-channel-metadata.ts +++ b/src/plugins/bundled-package-channel-metadata.ts @@ -7,8 +7,6 @@ import { type PluginPackageChannel, } from "./manifest.js"; -let bundledPackageChannelMetadataCache: readonly PluginPackageChannel[] | undefined; - function readPackageManifest(pluginDir: string): PackageManifest | undefined { const packagePath = path.join(pluginDir, "package.json"); if (!fs.existsSync(packagePath)) { @@ -22,21 +20,16 @@ function readPackageManifest(pluginDir: string): PackageManifest | undefined { } export function listBundledPackageChannelMetadata(): readonly PluginPackageChannel[] { - if (bundledPackageChannelMetadataCache) { - return bundledPackageChannelMetadataCache; - } const scanDir = resolveBundledPluginsDir(); if (!scanDir || !fs.existsSync(scanDir)) { - bundledPackageChannelMetadataCache = []; - return bundledPackageChannelMetadataCache; + return []; } - bundledPackageChannelMetadataCache = fs + return fs .readdirSync(scanDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => readPackageManifest(path.join(scanDir, entry.name))) .map((manifest) => getPackageManifestMetadata(manifest)?.channel) .filter((channel): channel is PluginPackageChannel => Boolean(channel?.id)); - return bundledPackageChannelMetadataCache; } export function findBundledPackageChannelMetadata( diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 9b80a107ac7..3c4af62f2a2 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -493,6 +493,35 @@ describe("bundled plugin metadata", () => { ).toBe(path.join(pluginRoot, "index.ts")); }); + it("reflects bundled manifest edits on the next metadata read", () => { + const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-fresh-"); + const pluginRoot = path.join(tempRoot, "extensions", "alpha"); + + writeJson(path.join(pluginRoot, "package.json"), { + name: "@openclaw/alpha", + version: "0.0.1", + openclaw: { + extensions: ["./index.ts"], + }, + }); + fs.writeFileSync(path.join(pluginRoot, "index.ts"), "export const source = true;\n", "utf8"); + writeJson(path.join(pluginRoot, "openclaw.plugin.json"), { + id: "alpha", + name: "Before", + configSchema: { type: "object" }, + }); + + expect(listBundledPluginMetadata({ rootDir: tempRoot })[0]?.manifest.name).toBe("Before"); + + writeJson(path.join(pluginRoot, "openclaw.plugin.json"), { + id: "alpha", + name: "After", + configSchema: { type: "object" }, + }); + + expect(listBundledPluginMetadata({ rootDir: tempRoot })[0]?.manifest.name).toBe("After"); + }); + it("prefers direct scan-dir overrides over nested dist artifacts within the same override root", () => { const pluginsDir = createGeneratedPluginTempRoot("openclaw-bundled-plugin-direct-priority-"); const pluginRoot = path.join(pluginsDir, "alpha"); diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index 27b83ad4ab1..247bd73b158 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -49,10 +49,9 @@ export type BundledPluginMetadata = { manifest: PluginManifest; }; -const bundledPluginMetadataCache = new Map(); - export function clearBundledPluginMetadataCache(): void { - bundledPluginMetadataCache.clear(); + // Bundled plugin metadata is read fresh. Keep the reset hook as a + // compatibility no-op for tests and older callers. } function readPackageManifest(pluginDir: string): PackageManifest | undefined { @@ -192,17 +191,7 @@ export function listBundledPluginMetadata(params?: { const includeChannelConfigs = params?.includeChannelConfigs ?? !RUNNING_FROM_BUILT_ARTIFACT; const includeSyntheticChannelConfigs = params?.includeSyntheticChannelConfigs ?? includeChannelConfigs; - const cacheKey = JSON.stringify({ - rootDir, - scanDir, - includeChannelConfigs, - includeSyntheticChannelConfigs, - }); - const cached = bundledPluginMetadataCache.get(cacheKey); - if (cached) { - return cached; - } - const entries = Object.freeze( + return Object.freeze( collectBundledPluginMetadata( rootDir, includeChannelConfigs, @@ -210,8 +199,6 @@ export function listBundledPluginMetadata(params?: { scanDir, ), ); - bundledPluginMetadataCache.set(cacheKey, entries); - return entries; } export function findBundledPluginMetadataById( diff --git a/src/plugins/cache-controls.ts b/src/plugins/cache-controls.ts deleted file mode 100644 index 6ece26892e0..00000000000 --- a/src/plugins/cache-controls.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { normalizeOptionalString } from "../shared/string-coerce.js"; - -export const DEFAULT_PLUGIN_DISCOVERY_CACHE_MS = 1000; -export const DEFAULT_PLUGIN_MANIFEST_CACHE_MS = 1000; - -export function shouldUsePluginSnapshotCache(env: NodeJS.ProcessEnv): boolean { - if (normalizeOptionalString(env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE)) { - return false; - } - if (normalizeOptionalString(env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE)) { - return false; - } - const discoveryCacheMs = normalizeOptionalString(env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS); - if (discoveryCacheMs === "0") { - return false; - } - const manifestCacheMs = normalizeOptionalString(env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS); - if (manifestCacheMs === "0") { - return false; - } - return true; -} - -export function resolvePluginCacheMs(rawValue: string | undefined, defaultMs: number): number { - const raw = normalizeOptionalString(rawValue); - if (raw === "" || raw === "0") { - return 0; - } - if (!raw) { - return defaultMs; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return defaultMs; - } - return Math.max(0, parsed); -} - -export function resolvePluginSnapshotCacheTtlMs(env: NodeJS.ProcessEnv): number { - const discoveryCacheMs = resolvePluginCacheMs( - env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, - DEFAULT_PLUGIN_DISCOVERY_CACHE_MS, - ); - const manifestCacheMs = resolvePluginCacheMs( - env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, - DEFAULT_PLUGIN_MANIFEST_CACHE_MS, - ); - return Math.min(discoveryCacheMs, manifestCacheMs); -} - -export function buildPluginSnapshotCacheEnvKey(env: NodeJS.ProcessEnv): string { - return JSON.stringify({ - OPENCLAW_BUNDLED_PLUGINS_DIR: env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", - OPENCLAW_HOME: env.OPENCLAW_HOME ?? "", - OPENCLAW_STATE_DIR: env.OPENCLAW_STATE_DIR ?? "", - OPENCLAW_CONFIG_PATH: env.OPENCLAW_CONFIG_PATH ?? "", - HOME: env.HOME ?? "", - USERPROFILE: env.USERPROFILE ?? "", - VITEST: env.VITEST ?? "", - }); -} diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index b7adac097eb..4a8923aae5f 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ resolveRuntimePluginRegistry: vi.fn< (params?: unknown) => ReturnType | undefined >(() => undefined), + resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)), loadPluginManifestRegistry: vi.fn<(params?: Record) => MockManifestRegistry>( () => createEmptyMockManifestRegistry(), ), @@ -35,6 +36,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("./loader.js", () => ({ resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry, + resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey, })); vi.mock("./manifest-registry-installed.js", () => ({ @@ -65,6 +67,7 @@ vi.mock("./bundled-compat.js", () => ({ let resolvePluginCapabilityProviders: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProviders; let resolvePluginCapabilityProvider: typeof import("./capability-provider-runtime.js").resolvePluginCapabilityProvider; +let clearCapabilityProviderPluginIdCacheForTests: typeof import("./capability-provider-runtime.js").__testing.clearCapabilityProviderPluginIdCacheForTests; function expectResolvedCapabilityProviderIds(providers: Array<{ id: string }>, expected: string[]) { expect(providers.map((provider) => provider.id)).toEqual(expected); @@ -168,13 +171,21 @@ function expectCompatChainApplied(params: { describe("resolvePluginCapabilityProviders", () => { beforeAll(async () => { - ({ resolvePluginCapabilityProvider, resolvePluginCapabilityProviders } = - await import("./capability-provider-runtime.js")); + ({ + resolvePluginCapabilityProvider, + resolvePluginCapabilityProviders, + __testing: { clearCapabilityProviderPluginIdCacheForTests }, + } = await import("./capability-provider-runtime.js")); }); beforeEach(() => { + clearCapabilityProviderPluginIdCacheForTests(); mocks.resolveRuntimePluginRegistry.mockReset(); mocks.resolveRuntimePluginRegistry.mockReturnValue(undefined); + mocks.resolvePluginRegistryLoadCacheKey.mockReset(); + mocks.resolvePluginRegistryLoadCacheKey.mockImplementation((options: unknown) => + JSON.stringify(options), + ); mocks.loadPluginRegistrySnapshot.mockReset(); mocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] }); mocks.loadPluginManifestRegistry.mockReset(); @@ -502,7 +513,7 @@ describe("resolvePluginCapabilityProviders", () => { }); }); - it("reuses manifest-derived capability plugin ids for the same config snapshot", () => { + it("reads manifest-derived capability plugin ids for each config snapshot", () => { const { cfg, enablementCompat } = createCompatChainConfig(); setBundledCapabilityFixture("mediaUnderstandingProviders"); mocks.withBundledPluginEnablementCompat.mockReturnValue(enablementCompat); @@ -515,7 +526,7 @@ describe("resolvePluginCapabilityProviders", () => { resolvePluginCapabilityProviders({ key: "mediaUnderstandingProviders", cfg }), ); - expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledOnce(); + expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2); expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledTimes(2); expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledWith({ config: cfg, @@ -523,6 +534,38 @@ describe("resolvePluginCapabilityProviders", () => { }); }); + it("resolves manifest-derived capability plugin ids for equivalent config snapshots independently", () => { + const first = createCompatChainConfig(); + const second = createCompatChainConfig(); + setBundledCapabilityFixture("mediaUnderstandingProviders"); + mocks.withBundledPluginEnablementCompat.mockReturnValue(first.enablementCompat); + mocks.withBundledPluginVitestCompat.mockReturnValue(first.enablementCompat); + + expectNoResolvedCapabilityProviders( + resolvePluginCapabilityProviders({ + key: "mediaUnderstandingProviders", + cfg: first.cfg, + }), + ); + expectNoResolvedCapabilityProviders( + resolvePluginCapabilityProviders({ + key: "mediaUnderstandingProviders", + cfg: second.cfg, + }), + ); + + expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2); + expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenCalledTimes(2); + expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenNthCalledWith(1, { + config: first.cfg, + pluginIds: ["openai"], + }); + expect(mocks.withBundledPluginAllowlistCompat).toHaveBeenNthCalledWith(2, { + config: second.cfg, + pluginIds: ["openai"], + }); + }); + it("reuses a compatible active registry even when the capability list is empty", () => { const active = createEmptyPluginRegistry(); mocks.resolveRuntimePluginRegistry.mockReturnValue(active); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index e0c44dd5174..4950170c6d6 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -4,11 +4,6 @@ import { withBundledPluginEnablementCompat, withBundledPluginVitestCompat, } from "./bundled-compat.js"; -import { - buildPluginSnapshotCacheEnvKey, - resolvePluginSnapshotCacheTtlMs, - shouldUsePluginSnapshotCache, -} from "./cache-controls.js"; import { hasExplicitPluginConfig } from "./config-policy.js"; import { resolveRuntimePluginRegistry } from "./loader.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; @@ -37,11 +32,6 @@ type CapabilityContractKey = type CapabilityProviderForKey = PluginRegistry[K][number] extends { provider: infer T } ? T : never; -type CapabilityProviderPluginIdCacheEntry = { - expiresAt: number; - pluginIds: string[]; -}; - const CAPABILITY_CONTRACT_KEY: Record = { memoryEmbeddingProviders: "memoryEmbeddingProviders", speechProviders: "speechProviders", @@ -53,68 +43,14 @@ const CAPABILITY_CONTRACT_KEY: Record> ->(); - -function buildCapabilityProviderPluginIdCacheKey(params: { - key: CapabilityProviderRegistryKey; - env: NodeJS.ProcessEnv; - providerId?: string; -}): string { - return JSON.stringify({ - key: params.key, - providerId: params.providerId ?? "", - env: buildPluginSnapshotCacheEnvKey(params.env), - }); +function clearCapabilityProviderPluginIdCacheForTests(): void { + // Capability owner ids are read from the manifest registry on demand. + // Keep the test hook as a compatibility no-op. } -function getCachedCapabilityProviderPluginIds(params: { - key: CapabilityProviderRegistryKey; - cfg?: OpenClawConfig; - env: NodeJS.ProcessEnv; - providerId?: string; -}): string[] | undefined { - if (!params.cfg || !shouldUsePluginSnapshotCache(params.env)) { - return undefined; - } - const envCache = capabilityProviderPluginIdCache.get(params.cfg)?.get(params.env); - const cached = envCache?.get(buildCapabilityProviderPluginIdCacheKey(params)); - if (!cached || cached.expiresAt <= Date.now()) { - return undefined; - } - return [...cached.pluginIds]; -} - -function memoizeCapabilityProviderPluginIds(params: { - key: CapabilityProviderRegistryKey; - cfg?: OpenClawConfig; - env: NodeJS.ProcessEnv; - providerId?: string; - pluginIds: string[]; -}): void { - if (!params.cfg || !shouldUsePluginSnapshotCache(params.env)) { - return; - } - let configCache = capabilityProviderPluginIdCache.get(params.cfg); - if (!configCache) { - configCache = new WeakMap< - NodeJS.ProcessEnv, - Map - >(); - capabilityProviderPluginIdCache.set(params.cfg, configCache); - } - let envCache = configCache.get(params.env); - if (!envCache) { - envCache = new Map(); - configCache.set(params.env, envCache); - } - envCache.set(buildCapabilityProviderPluginIdCacheKey(params), { - expiresAt: Date.now() + resolvePluginSnapshotCacheTtlMs(params.env), - pluginIds: [...params.pluginIds], - }); -} +export const __testing = { + clearCapabilityProviderPluginIdCacheForTests, +} as const; function resolveBundledCapabilityCompatPluginIds(params: { key: CapabilityProviderRegistryKey; @@ -122,15 +58,8 @@ function resolveBundledCapabilityCompatPluginIds(params: { providerId?: string; }): string[] { const env = process.env; - const cached = getCachedCapabilityProviderPluginIds({ - ...params, - env, - }); - if (cached) { - return cached; - } const contractKey = CAPABILITY_CONTRACT_KEY[params.key]; - const pluginIds = loadPluginManifestRegistryForPluginRegistry({ + return loadPluginManifestRegistryForPluginRegistry({ config: params.cfg, env, includeDisabled: true, @@ -143,12 +72,6 @@ function resolveBundledCapabilityCompatPluginIds(params: { ) .map((plugin) => plugin.id) .toSorted((left, right) => left.localeCompare(right)); - memoizeCapabilityProviderPluginIds({ - ...params, - env, - pluginIds, - }); - return pluginIds; } function resolveCapabilityProviderConfig(params: { diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 55ce469ab55..136d900f380 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -415,8 +415,6 @@ describe("registerPluginCommand", () => { ...process.env, OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", }; expect(getPluginCommandSpecs("discord", { env })).toEqual([]); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index cf040253d2d..b109fb1484a 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -33,8 +33,6 @@ export type PluginActivationConfigSource = { export type NormalizedPluginsConfig = SharedNormalizedPluginsConfig; -let bundledPluginAliasLookupCache: ReadonlyMap | undefined; - const BUILT_IN_PLUGIN_ALIAS_FALLBACKS: ReadonlyArray = [ ["openai-codex", "openai"], ["google-gemini-cli", "google"], @@ -47,10 +45,6 @@ const BUILT_IN_PLUGIN_ALIAS_LOOKUP = new Map([ ]); function getBundledPluginAliasLookup(): ReadonlyMap { - if (bundledPluginAliasLookupCache) { - return bundledPluginAliasLookupCache; - } - const lookup = new Map(); for (const plugin of listBundledPluginMetadata({ includeChannelConfigs: false })) { const pluginId = normalizeOptionalLowercaseString(plugin.manifest.id); @@ -73,7 +67,6 @@ function getBundledPluginAliasLookup(): ReadonlyMap { for (const [alias, pluginId] of BUILT_IN_PLUGIN_ALIAS_FALLBACKS) { lookup.set(alias, pluginId); } - bundledPluginAliasLookupCache = lookup; return lookup; } diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index a9f094eb26b..0a2f33a9b42 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -219,33 +219,6 @@ function resolveBundledManifestPluginIdsForContract(contract: ManifestContractKe ).toSorted((left, right) => left.localeCompare(right)); } -let providerContractRegistryCache: ProviderContractEntry[] | null = null; -let providerContractRegistryByPluginIdCache: Map | null = null; -let webFetchProviderContractRegistryCache: WebFetchProviderContractEntry[] | null = null; -let webFetchProviderContractRegistryByPluginIdCache: Map< - string, - WebFetchProviderContractEntry[] -> | null = null; -let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null; -let webSearchProviderContractRegistryByPluginIdCache: Map< - string, - WebSearchProviderContractEntry[] -> | null = null; -let speechProviderContractRegistryCache: SpeechProviderContractEntry[] | null = null; -let realtimeTranscriptionProviderContractRegistryCache: - | RealtimeTranscriptionProviderContractEntry[] - | null = null; -let realtimeVoiceProviderContractRegistryCache: RealtimeVoiceProviderContractEntry[] | null = null; -let mediaUnderstandingProviderContractRegistryCache: - | MediaUnderstandingProviderContractEntry[] - | null = null; -let imageGenerationProviderContractRegistryCache: ImageGenerationProviderContractEntry[] | null = - null; -let videoGenerationProviderContractRegistryCache: VideoGenerationProviderContractEntry[] | null = - null; -let musicGenerationProviderContractRegistryCache: MusicGenerationProviderContractEntry[] | null = - null; - export let providerContractLoadError: Error | undefined; function formatBundledCapabilityPluginLoadError(params: { @@ -323,23 +296,10 @@ function loadProviderContractEntriesForPluginIds( } function loadProviderContractEntriesForPluginId(pluginId: string): ProviderContractEntry[] { - if (providerContractRegistryCache) { - return providerContractRegistryCache.filter((entry) => entry.pluginId === pluginId); - } - - const cache = - providerContractRegistryByPluginIdCache ?? new Map(); - providerContractRegistryByPluginIdCache = cache; - const cached = cache.get(pluginId); - if (cached) { - return cached; - } - const publicArtifactEntries = resolveBundledExplicitProviderContractsFromPublicArtifacts({ onlyPluginIds: [pluginId], }); if (publicArtifactEntries) { - cache.set(pluginId, publicArtifactEntries); return publicArtifactEntries; } @@ -360,47 +320,42 @@ function loadProviderContractEntriesForPluginId(pluginId: string): ProviderContr pluginId: entry.pluginId, provider: entry.provider, })); - cache.set(pluginId, entries); return entries; } catch (error) { providerContractLoadError = error instanceof Error ? error : new Error(String(error)); - cache.set(pluginId, []); return []; } } function loadProviderContractRegistry(): ProviderContractEntry[] { - if (!providerContractRegistryCache) { - try { - providerContractLoadError = undefined; - const pluginIds = resolveBundledProviderContractPluginIds(); - const publicArtifactEntries = pluginIds.flatMap( - (pluginId) => - resolveBundledExplicitProviderContractsFromPublicArtifacts({ - onlyPluginIds: [pluginId], - }) ?? [], - ); - const coveredPluginIds = new Set(publicArtifactEntries.map((entry) => entry.pluginId)); - const remainingPluginIds = resolveBundledProviderContractPluginIds().filter( - (pluginId) => !coveredPluginIds.has(pluginId), - ); - const runtimeEntries = - remainingPluginIds.length > 0 - ? loadBundledCapabilityRuntimeRegistry({ - pluginIds: remainingPluginIds, - pluginSdkResolution: "dist", - }).providers.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })) - : []; - providerContractRegistryCache = [...publicArtifactEntries, ...runtimeEntries]; - } catch (error) { - providerContractLoadError = error instanceof Error ? error : new Error(String(error)); - providerContractRegistryCache = []; - } + try { + providerContractLoadError = undefined; + const pluginIds = resolveBundledProviderContractPluginIds(); + const publicArtifactEntries = pluginIds.flatMap( + (pluginId) => + resolveBundledExplicitProviderContractsFromPublicArtifacts({ + onlyPluginIds: [pluginId], + }) ?? [], + ); + const coveredPluginIds = new Set(publicArtifactEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledProviderContractPluginIds().filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).providers.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })) + : []; + return [...publicArtifactEntries, ...runtimeEntries]; + } catch (error) { + providerContractLoadError = error instanceof Error ? error : new Error(String(error)); + return []; } - return providerContractRegistryCache; } function loadUniqueProviderContractProviders(): ProviderPlugin[] { @@ -449,37 +404,21 @@ function resolveWebFetchCredentialValue(provider: WebFetchProviderPlugin): unkno } function loadWebFetchProviderContractRegistry(): WebFetchProviderContractEntry[] { - if (!webFetchProviderContractRegistryCache) { - const registry = loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestContractPluginIds("webFetchProviders"), - pluginSdkResolution: "dist", - }); - webFetchProviderContractRegistryCache = registry.webFetchProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - credentialValue: resolveWebFetchCredentialValue(entry.provider), - })); - } - return webFetchProviderContractRegistryCache; + const registry = loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestContractPluginIds("webFetchProviders"), + pluginSdkResolution: "dist", + }); + return registry.webFetchProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: resolveWebFetchCredentialValue(entry.provider), + })); } export function resolveWebFetchProviderContractEntriesForPluginId( pluginId: string, ): WebFetchProviderContractEntry[] { - if (webFetchProviderContractRegistryCache) { - return webFetchProviderContractRegistryCache.filter((entry) => entry.pluginId === pluginId); - } - - const cache = - webFetchProviderContractRegistryByPluginIdCache ?? - new Map(); - webFetchProviderContractRegistryByPluginIdCache = cache; - const cached = cache.get(pluginId); - if (cached) { - return cached; - } - - const entries = loadScopedCapabilityRuntimeRegistryEntries({ + return loadScopedCapabilityRuntimeRegistryEntries({ pluginId, capabilityLabel: "web fetch provider", loadEntries: (registry) => @@ -492,60 +431,42 @@ export function resolveWebFetchProviderContractEntriesForPluginId( })), loadDeclaredIds: (plugin) => plugin.webFetchProviderIds, }); - cache.set(pluginId, entries); - return entries; } function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { - if (!webSearchProviderContractRegistryCache) { - const pluginIds = resolveBundledManifestContractPluginIds("webSearchProviders"); - const publicArtifactEntries = pluginIds.flatMap((pluginId) => - ( - resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ - onlyPluginIds: [pluginId], - }) ?? [] - ).map((provider) => ({ - pluginId: provider.pluginId, - provider, - credentialValue: resolveWebSearchCredentialValue(provider), - })), - ); - const coveredPluginIds = new Set(publicArtifactEntries.map((entry) => entry.pluginId)); - const remainingPluginIds = resolveBundledManifestContractPluginIds("webSearchProviders").filter( - (pluginId) => !coveredPluginIds.has(pluginId), - ); - const runtimeEntries = - remainingPluginIds.length > 0 - ? loadBundledCapabilityRuntimeRegistry({ - pluginIds: remainingPluginIds, - pluginSdkResolution: "dist", - }).webSearchProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - credentialValue: resolveWebSearchCredentialValue(entry.provider), - })) - : []; - webSearchProviderContractRegistryCache = [...publicArtifactEntries, ...runtimeEntries]; - } - return webSearchProviderContractRegistryCache; + const pluginIds = resolveBundledManifestContractPluginIds("webSearchProviders"); + const publicArtifactEntries = pluginIds.flatMap((pluginId) => + ( + resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ + onlyPluginIds: [pluginId], + }) ?? [] + ).map((provider) => ({ + pluginId: provider.pluginId, + provider, + credentialValue: resolveWebSearchCredentialValue(provider), + })), + ); + const coveredPluginIds = new Set(publicArtifactEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledManifestContractPluginIds("webSearchProviders").filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).webSearchProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: resolveWebSearchCredentialValue(entry.provider), + })) + : []; + return [...publicArtifactEntries, ...runtimeEntries]; } export function resolveWebSearchProviderContractEntriesForPluginId( pluginId: string, ): WebSearchProviderContractEntry[] { - if (webSearchProviderContractRegistryCache) { - return webSearchProviderContractRegistryCache.filter((entry) => entry.pluginId === pluginId); - } - - const cache = - webSearchProviderContractRegistryByPluginIdCache ?? - new Map(); - webSearchProviderContractRegistryByPluginIdCache = cache; - const cached = cache.get(pluginId); - if (cached) { - return cached; - } - const publicArtifactEntries = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ onlyPluginIds: [pluginId], })?.map((provider) => ({ @@ -554,11 +475,10 @@ export function resolveWebSearchProviderContractEntriesForPluginId( credentialValue: resolveWebSearchCredentialValue(provider), })); if (publicArtifactEntries) { - cache.set(pluginId, publicArtifactEntries); return publicArtifactEntries; } - const entries = loadScopedCapabilityRuntimeRegistryEntries({ + return loadScopedCapabilityRuntimeRegistryEntries({ pluginId, capabilityLabel: "web search provider", loadEntries: (registry) => @@ -571,113 +491,90 @@ export function resolveWebSearchProviderContractEntriesForPluginId( })), loadDeclaredIds: (plugin) => plugin.webSearchProviderIds, }); - cache.set(pluginId, entries); - return entries; } function loadSpeechProviderContractRegistry(): SpeechProviderContractEntry[] { - if (!speechProviderContractRegistryCache) { - speechProviderContractRegistryCache = process.env.VITEST - ? loadVitestSpeechProviderContractRegistry() - : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("speechProviders"), - pluginSdkResolution: "dist", - }).speechProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } - return speechProviderContractRegistryCache; + return process.env.VITEST + ? loadVitestSpeechProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("speechProviders"), + pluginSdkResolution: "dist", + }).speechProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); } function loadRealtimeVoiceProviderContractRegistry(): RealtimeVoiceProviderContractEntry[] { - if (!realtimeVoiceProviderContractRegistryCache) { - realtimeVoiceProviderContractRegistryCache = process.env.VITEST - ? loadVitestRealtimeVoiceProviderContractRegistry() - : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("realtimeVoiceProviders"), - pluginSdkResolution: "dist", - }).realtimeVoiceProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } - return realtimeVoiceProviderContractRegistryCache; + return process.env.VITEST + ? loadVitestRealtimeVoiceProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("realtimeVoiceProviders"), + pluginSdkResolution: "dist", + }).realtimeVoiceProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); } function loadRealtimeTranscriptionProviderContractRegistry(): RealtimeTranscriptionProviderContractEntry[] { - if (!realtimeTranscriptionProviderContractRegistryCache) { - realtimeTranscriptionProviderContractRegistryCache = process.env.VITEST - ? loadVitestRealtimeTranscriptionProviderContractRegistry() - : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("realtimeTranscriptionProviders"), - pluginSdkResolution: "dist", - }).realtimeTranscriptionProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } - return realtimeTranscriptionProviderContractRegistryCache; + return process.env.VITEST + ? loadVitestRealtimeTranscriptionProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("realtimeTranscriptionProviders"), + pluginSdkResolution: "dist", + }).realtimeTranscriptionProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); } function loadMediaUnderstandingProviderContractRegistry(): MediaUnderstandingProviderContractEntry[] { - if (!mediaUnderstandingProviderContractRegistryCache) { - mediaUnderstandingProviderContractRegistryCache = process.env.VITEST - ? loadVitestMediaUnderstandingProviderContractRegistry() - : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("mediaUnderstandingProviders"), - pluginSdkResolution: "dist", - }).mediaUnderstandingProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } - return mediaUnderstandingProviderContractRegistryCache; + return process.env.VITEST + ? loadVitestMediaUnderstandingProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("mediaUnderstandingProviders"), + pluginSdkResolution: "dist", + }).mediaUnderstandingProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); } function loadImageGenerationProviderContractRegistry(): ImageGenerationProviderContractEntry[] { - if (!imageGenerationProviderContractRegistryCache) { - imageGenerationProviderContractRegistryCache = process.env.VITEST - ? loadVitestImageGenerationProviderContractRegistry() - : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("imageGenerationProviders"), - pluginSdkResolution: "dist", - }).imageGenerationProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } - return imageGenerationProviderContractRegistryCache; + return process.env.VITEST + ? loadVitestImageGenerationProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("imageGenerationProviders"), + pluginSdkResolution: "dist", + }).imageGenerationProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); } function loadVideoGenerationProviderContractRegistry(): VideoGenerationProviderContractEntry[] { - if (!videoGenerationProviderContractRegistryCache) { - videoGenerationProviderContractRegistryCache = process.env.VITEST - ? loadVitestVideoGenerationProviderContractRegistry() - : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("videoGenerationProviders"), - pluginSdkResolution: "dist", - }).videoGenerationProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } - return videoGenerationProviderContractRegistryCache; + return process.env.VITEST + ? loadVitestVideoGenerationProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("videoGenerationProviders"), + pluginSdkResolution: "dist", + }).videoGenerationProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); } function loadMusicGenerationProviderContractRegistry(): MusicGenerationProviderContractEntry[] { - if (!musicGenerationProviderContractRegistryCache) { - musicGenerationProviderContractRegistryCache = process.env.VITEST - ? loadVitestMusicGenerationProviderContractRegistry() - : loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestPluginIdsForContract("musicGenerationProviders"), - pluginSdkResolution: "dist", - }).musicGenerationProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); - } - return musicGenerationProviderContractRegistryCache; + return process.env.VITEST + ? loadVitestMusicGenerationProviderContractRegistry() + : loadBundledCapabilityRuntimeRegistry({ + pluginIds: resolveBundledManifestPluginIdsForContract("musicGenerationProviders"), + pluginSdkResolution: "dist", + }).musicGenerationProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })); } function createLazyArrayView(load: () => T[]): T[] { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 949495fd6ed..1d29440c251 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -27,6 +27,18 @@ function makeTempDir() { const mkdirSafe = mkdirSafeDir; +function withOpenClawPackageArgv(packageRoot: string, fn: () => T): T { + mkdirSafe(path.join(packageRoot, "bin")); + fs.writeFileSync(path.join(packageRoot, "package.json"), '{"name":"openclaw"}\n', "utf-8"); + const originalArgv = process.argv; + process.argv = [originalArgv[0] ?? "node", path.join(packageRoot, "bin", "openclaw")]; + try { + return fn(); + } finally { + process.argv = originalArgv; + } +} + function symlinkDirectory(target: string, linkPath: string): void { fs.symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir"); } @@ -74,17 +86,28 @@ function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv { }; } -function buildCachedDiscoveryEnv( +function buildDiscoveryEnvWithOverrides( stateDir: string, overrides: Partial = {}, ): NodeJS.ProcessEnv { + const enablesBundledOverride = + Object.prototype.hasOwnProperty.call(overrides, "OPENCLAW_BUNDLED_PLUGINS_DIR") && + overrides.OPENCLAW_BUNDLED_PLUGINS_DIR !== undefined; return { ...buildDiscoveryEnv(stateDir), - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", + ...(enablesBundledOverride ? { OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined } : {}), ...overrides, }; } +function buildBundledDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv { + return { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + }; +} + async function discoverWithStateDir( stateDir: string, params: Parameters[0], @@ -92,7 +115,7 @@ async function discoverWithStateDir( return discoverOpenClawPlugins({ ...params, env: buildDiscoveryEnv(stateDir) }); } -function discoverWithCachedEnv(params: Parameters[0]) { +function discoverWithEnv(params: Parameters[0]) { return discoverOpenClawPlugins(params); } @@ -277,17 +300,6 @@ function expectBundleCandidateMatch(params: { } } -function expectCachedDiscoveryPair(params: { - first: ReturnType; - second: ReturnType; - assert: ( - first: ReturnType, - second: ReturnType, - ) => void; -}) { - params.assert(params.first, params.second); -} - async function expectRejectedPackageExtensionEntry(params: { stateDir: string; setup: (stateDir: string) => boolean | void; @@ -479,7 +491,8 @@ describe("discoverOpenClawPlugins", () => { it("does not treat repo-level live or test files as plugin entrypoints", () => { const stateDir = makeTempDir(); - const bundledDir = path.join(stateDir, "bundled"); + const packageRoot = path.join(stateDir, "node_modules", "openclaw"); + const bundledDir = path.join(packageRoot, "dist", "extensions"); mkdirSafe(bundledDir); writeStandalonePlugin( @@ -492,13 +505,15 @@ describe("discoverOpenClawPlugins", () => { ); writeStandalonePlugin(path.join(bundledDir, "real-plugin.ts"), "export default {}"); - const { candidates, diagnostics } = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - }, - }); + const { candidates, diagnostics } = withOpenClawPackageArgv(packageRoot, () => + discoverOpenClawPlugins({ + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, + }, + }), + ); expectCandidateOrder(candidates, ["real-plugin"]); expect(diagnostics).toEqual([]); @@ -513,14 +528,16 @@ describe("discoverOpenClawPlugins", () => { writePluginManifest({ pluginDir: bundledPluginDir, id: "feishu" }); writePluginEntry(path.join(bundledPluginDir, "index.js")); - const { candidates, diagnostics } = discoverOpenClawPlugins({ - extraPaths: [bundledPluginDir], - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, - }, - }); + const { candidates, diagnostics } = withOpenClawPackageArgv(packageRoot, () => + discoverOpenClawPlugins({ + extraPaths: [bundledPluginDir], + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }), + ); expect(candidates.filter((candidate) => candidate.idHint === "feishu")).toEqual([ expect.objectContaining({ origin: "bundled" }), @@ -542,19 +559,22 @@ describe("discoverOpenClawPlugins", () => { const legacyPluginDir = path.join(packageRoot, "extensions", "telegram"); mkdirSafe(bundledPluginDir); mkdirSafe(legacyPluginDir); + mkdirSafe(path.join(packageRoot, "dist", "extensions")); writePluginManifest({ pluginDir: bundledPluginDir, id: "telegram" }); writePluginManifest({ pluginDir: legacyPluginDir, id: "telegram" }); writePluginEntry(path.join(bundledPluginDir, "index.js")); writePluginEntry(path.join(legacyPluginDir, "index.js")); - const { candidates, diagnostics } = discoverOpenClawPlugins({ - extraPaths: [legacyPluginDir], - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, - }, - }); + const { candidates, diagnostics } = withOpenClawPackageArgv(packageRoot, () => + discoverOpenClawPlugins({ + extraPaths: [legacyPluginDir], + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }), + ); expect(candidates.filter((candidate) => candidate.idHint === "telegram")).toEqual([ expect.objectContaining({ origin: "bundled" }), @@ -589,13 +609,15 @@ describe("discoverOpenClawPlugins", () => { const sourceEntryPath = path.join(sourcePluginDir, "src", "index.ts"); const bundledEntryPath = path.join(bundledPluginDir, "index.js"); - const { candidates, diagnostics } = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, - }, - }); + const { candidates, diagnostics } = withOpenClawPackageArgv(packageRoot, () => + discoverOpenClawPlugins({ + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }), + ); const synologyCandidates = candidates.filter( (candidate) => candidate.idHint === "synology-chat", @@ -641,13 +663,15 @@ describe("discoverOpenClawPlugins", () => { mockLinuxMountInfo([]); const bundledEntryPath = path.join(bundledPluginDir, "index.js"); - const { candidates, diagnostics } = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, - }, - }); + const { candidates, diagnostics } = withOpenClawPackageArgv(packageRoot, () => + discoverOpenClawPlugins({ + env: { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }, + }), + ); expect(candidates.filter((candidate) => candidate.idHint === "synology-chat")).toEqual([ expect.objectContaining({ @@ -1344,20 +1368,18 @@ describe("discoverOpenClawPlugins", () => { "repairs world-writable bundled plugin dirs before loading them", async () => { const stateDir = makeTempDir(); - const bundledDir = path.join(stateDir, "bundled"); + const packageRoot = path.join(stateDir, "node_modules", "openclaw"); + const bundledDir = path.join(packageRoot, "dist", "extensions"); const packDir = path.join(bundledDir, "demo-pack"); mkdirSafe(packDir); fs.writeFileSync(path.join(packDir, "index.ts"), "export default function () {}", "utf-8"); fs.chmodSync(packDir, 0o777); - const result = discoverOpenClawPlugins({ - env: { - ...process.env, - OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - }, - }); + const result = withOpenClawPackageArgv(packageRoot, () => + discoverOpenClawPlugins({ + env: { ...process.env, ...buildBundledDiscoveryEnv(stateDir) }, + }), + ); expect(result.candidates.some((candidate) => candidate.idHint === "demo-pack")).toBe(true); expect( @@ -1391,31 +1413,27 @@ describe("discoverOpenClawPlugins", () => { }, ); - it("reuses discovery results from cache until cleared", async () => { + it("reflects plugin root changes on the next discovery call", async () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions"); mkdirSafe(globalExt); - const pluginPath = path.join(globalExt, "cached.ts"); + const pluginPath = path.join(globalExt, "fresh.ts"); fs.writeFileSync(pluginPath, "export default function () {}", "utf-8"); - const cachedEnv = buildCachedDiscoveryEnv(stateDir); - const first = discoverWithCachedEnv({ env: cachedEnv }); - expect(first.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true); + const env = buildDiscoveryEnvWithOverrides(stateDir); + const first = discoverWithEnv({ env }); + expect(first.candidates.some((candidate) => candidate.idHint === "fresh")).toBe(true); fs.rmSync(pluginPath, { force: true }); - const second = discoverWithCachedEnv({ env: cachedEnv }); - expect(second.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true); - - clearPluginDiscoveryCache(); - - const third = discoverWithCachedEnv({ env: cachedEnv }); - expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false); + const second = discoverWithEnv({ env }); + expect(second.candidates.some((candidate) => candidate.idHint === "fresh")).toBe(false); }); - it("reuses bundled and global discovery across workspace-specific cache misses", () => { + it("discovers bundled and global plugins for each workspace-specific scan", () => { const stateDir = makeTempDir(); - const bundledDir = path.join(stateDir, "bundled"); + const packageRoot = path.join(stateDir, "node_modules", "openclaw"); + const bundledDir = path.join(packageRoot, "dist", "extensions"); const globalExt = path.join(stateDir, "extensions"); const workspaceA = path.join(stateDir, "workspace-a"); const workspaceB = path.join(stateDir, "workspace-b"); @@ -1441,27 +1459,26 @@ describe("discoverOpenClawPlugins", () => { pluginId: "workspace-b-plugin", }); - const env = buildCachedDiscoveryEnv(stateDir, { + const env = { + ...buildDiscoveryEnv(stateDir), OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, OPENCLAW_BUNDLED_PLUGINS_DIR: bundledDir, - }); - const readdirSync = vi.spyOn(fs, "readdirSync"); - const countSharedRootReads = () => - readdirSync.mock.calls.filter(([dir]) => dir === bundledDir || dir === globalExt).length; - - const first = discoverWithCachedEnv({ workspaceDir: workspaceA, env }); + }; + const first = withOpenClawPackageArgv(packageRoot, () => + discoverWithEnv({ workspaceDir: workspaceA, env }), + ); expectCandidatePresence(first, { present: ["bundled-plugin", "global-plugin", "workspace-a-plugin"], absent: ["workspace-b-plugin"], }); - expect(countSharedRootReads()).toBe(2); - const second = discoverWithCachedEnv({ workspaceDir: workspaceB, env }); + const second = withOpenClawPackageArgv(packageRoot, () => + discoverWithEnv({ workspaceDir: workspaceB, env }), + ); expectCandidatePresence(second, { present: ["bundled-plugin", "global-plugin", "workspace-b-plugin"], absent: ["workspace-a-plugin"], }); - expect(countSharedRootReads()).toBe(2); }); it.each([ @@ -1473,11 +1490,11 @@ describe("discoverOpenClawPlugins", () => { writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts")); writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts")); return { - first: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirA) }), - second: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirB) }), + first: discoverWithEnv({ env: buildDiscoveryEnvWithOverrides(stateDirA) }), + second: discoverWithEnv({ env: buildDiscoveryEnvWithOverrides(stateDirB) }), assert: ( - first: ReturnType, - second: ReturnType, + first: ReturnType, + second: ReturnType, ) => { expectCandidatePresence(first, { present: ["alpha"], absent: ["beta"] }); expectCandidatePresence(second, { present: ["beta"], absent: ["alpha"] }); @@ -1496,17 +1513,17 @@ describe("discoverOpenClawPlugins", () => { writeStandalonePlugin(pluginA, "export default {}"); writeStandalonePlugin(pluginB, "export default {}"); return { - first: discoverWithCachedEnv({ + first: discoverWithEnv({ extraPaths: ["~/plugins/demo.ts"], - env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }), + env: buildDiscoveryEnvWithOverrides(stateDir, { HOME: homeA }), }), - second: discoverWithCachedEnv({ + second: discoverWithEnv({ extraPaths: ["~/plugins/demo.ts"], - env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }), + env: buildDiscoveryEnvWithOverrides(stateDir, { HOME: homeB }), }), assert: ( - first: ReturnType, - second: ReturnType, + first: ReturnType, + second: ReturnType, ) => { expectCandidateSource(first.candidates, "demo", pluginA); expectCandidateSource(second.candidates, "demo", pluginB); @@ -1516,23 +1533,23 @@ describe("discoverOpenClawPlugins", () => { }, ] as const)("$name", ({ setup }) => { const { first, second, assert } = setup(); - expectCachedDiscoveryPair({ first, second, assert }); + assert(first, second); }); - it("treats configured load-path order as cache-significant", () => { + it("preserves configured load-path order", () => { const stateDir = makeTempDir(); const pluginA = path.join(stateDir, "plugins", "alpha.ts"); const pluginB = path.join(stateDir, "plugins", "beta.ts"); writeStandalonePlugin(pluginA, "export default {}"); writeStandalonePlugin(pluginB, "export default {}"); - const env = buildCachedDiscoveryEnv(stateDir); + const env = buildDiscoveryEnvWithOverrides(stateDir); - const first = discoverWithCachedEnv({ + const first = discoverWithEnv({ extraPaths: [pluginA, pluginB], env, }); - const second = discoverWithCachedEnv({ + const second = discoverWithEnv({ extraPaths: [pluginB, pluginA], env, }); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 65a1aa55c7b..bacf1dd9988 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -26,7 +26,7 @@ import { import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js"; import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; -import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; +import { resolvePluginSourceRoots } from "./roots.js"; const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); const SCANNED_DIRECTORY_IGNORE_NAMES = new Set([ @@ -65,64 +65,9 @@ export type PluginDiscoveryResult = { diagnostics: PluginDiagnostic[]; }; -const discoveryCache = new Map(); - -// Keep a short cache window to collapse bursty reloads during startup flows. -const DEFAULT_DISCOVERY_CACHE_MS = 1000; - export function clearPluginDiscoveryCache(): void { - discoveryCache.clear(); -} - -function resolveDiscoveryCacheMs(env: NodeJS.ProcessEnv): number { - const raw = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); - if (raw === "" || raw === "0") { - return 0; - } - if (!raw) { - return DEFAULT_DISCOVERY_CACHE_MS; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return DEFAULT_DISCOVERY_CACHE_MS; - } - return Math.max(0, parsed); -} - -function shouldUseDiscoveryCache(env: NodeJS.ProcessEnv): boolean { - const disabled = env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim(); - if (disabled) { - return false; - } - return resolveDiscoveryCacheMs(env) > 0; -} - -function buildScopedDiscoveryCacheKey(params: { - workspaceDir?: string; - extraPaths?: string[]; - ownershipUid?: number | null; - env: NodeJS.ProcessEnv; -}): string { - const { roots, loadPaths } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - loadPaths: params.extraPaths, - env: params.env, - }); - const workspaceKey = roots.workspace ?? ""; - const bundledRoot = roots.stock ?? ""; - const ownershipUid = params.ownershipUid ?? currentUid(); - return `scoped::${workspaceKey}::${bundledRoot}::${ownershipUid ?? "none"}::${JSON.stringify(loadPaths)}`; -} - -function buildSharedDiscoveryCacheKey(params: { - ownershipUid?: number | null; - env: NodeJS.ProcessEnv; -}): string { - const roots = resolvePluginSourceRoots({ env: params.env }); - const configExtensionsRoot = roots.global ?? ""; - const bundledRoot = roots.stock ?? ""; - const ownershipUid = params.ownershipUid ?? currentUid(); - return `shared::${ownershipUid ?? "none"}::${configExtensionsRoot}::${bundledRoot}`; + // Discovery is intentionally uncached. Keep the public test/helper hook as a + // compatibility no-op while callers migrate away from explicit cache clears. } function currentUid(overrideUid?: number | null): number | null { @@ -417,26 +362,6 @@ function mergeDiscoveryResult( target.diagnostics.push(...source.diagnostics); } -function getCachedDiscoveryResult(params: { - cacheEnabled: boolean; - cacheKey: string; - env: NodeJS.ProcessEnv; - load: () => PluginDiscoveryResult; -}): PluginDiscoveryResult { - const ttl = resolveDiscoveryCacheMs(params.env); - if (params.cacheEnabled) { - const cached = discoveryCache.get(params.cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.result; - } - } - const result = params.load(); - if (params.cacheEnabled && ttl > 0) { - discoveryCache.set(params.cacheKey, { expiresAt: Date.now() + ttl, result }); - } - return result; -} - function readPackageManifest( dir: string, rejectHardlinks = true, @@ -930,147 +855,126 @@ export function discoverOpenClawPlugins(params: { env?: NodeJS.ProcessEnv; }): PluginDiscoveryResult { const env = params.env ?? process.env; - const cacheEnabled = params.cache !== false && shouldUseDiscoveryCache(env); const workspaceDir = normalizeOptionalString(params.workspaceDir); const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined; const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env }); - const scopedResult = getCachedDiscoveryResult({ - cacheEnabled, - cacheKey: buildScopedDiscoveryCacheKey({ - workspaceDir: params.workspaceDir, - extraPaths: params.extraPaths, - ownershipUid: params.ownershipUid, - env, - }), - env, - load: () => - tracePluginLifecyclePhase( - "discovery scan", - () => { - const result = createDiscoveryResult(); - const seen = new Set(); - const realpathCache = new Map(); - const extra = params.extraPaths ?? []; - for (const extraPath of extra) { - if (typeof extraPath !== "string") { - continue; - } - const trimmed = extraPath.trim(); - if (!trimmed) { - continue; - } - const bundledAlias = resolvePackagedBundledLoadPathAlias({ - bundledRoot: roots.stock, - loadPath: resolveUserPath(trimmed, env), - }); - if (bundledAlias) { - result.diagnostics.push({ - level: "warn", - source: trimmed, - message: `ignored plugins.load.paths entry that points at OpenClaw's ${bundledAlias.kind} bundled plugin directory; remove this redundant path or run openclaw doctor --fix`, - }); - continue; - } - discoverFromPath({ - rawPath: trimmed, - origin: "config", - ownershipUid: params.ownershipUid, - workspaceDir, - env, - candidates: result.candidates, - diagnostics: result.diagnostics, - seen, - realpathCache, - }); - } - const workspaceMatchesBundledRoot = resolvesToSameDirectory( - workspaceRoot, - roots.stock, - realpathCache, - ); - if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) { - // Keep workspace auto-discovery constrained to the OpenClaw extensions root. - // Recursively scanning the full workspace treats arbitrary project folders as - // plugin candidates and causes noisy "plugin manifest not found" validation failures. - discoverInDirectory({ - dir: roots.workspace, - origin: "workspace", - ownershipUid: params.ownershipUid, - workspaceDir: workspaceRoot, - candidates: result.candidates, - diagnostics: result.diagnostics, - seen, - realpathCache, - }); - } - return result; - }, - { scope: "scoped", extraPathCount: params.extraPaths?.length ?? 0 }, - ), - }); - const sharedResult = getCachedDiscoveryResult({ - cacheEnabled, - cacheKey: buildSharedDiscoveryCacheKey({ - ownershipUid: params.ownershipUid, - env, - }), - env, - load: () => - tracePluginLifecyclePhase( - "discovery scan", - () => { - const result = createDiscoveryResult(); - const seen = new Set(); - const realpathCache = new Map(); - for (const sourceOverlayDir of listBundledSourceOverlayDirs({ - bundledRoot: roots.stock, - env, - })) { - discoverFromPath({ - rawPath: sourceOverlayDir, - origin: "bundled", - ownershipUid: params.ownershipUid, - workspaceDir, - env, - candidates: result.candidates, - diagnostics: result.diagnostics, - seen, - realpathCache, - }); - result.diagnostics.push({ - level: "warn", - source: sourceOverlayDir, - message: - "using bind-mounted bundled plugin source overlay; this source overrides the packaged dist bundle for the same plugin id", - }); - } - if (roots.stock) { - discoverInDirectory({ - dir: roots.stock, - origin: "bundled", - ownershipUid: params.ownershipUid, - candidates: result.candidates, - diagnostics: result.diagnostics, - seen, - realpathCache, - }); - } - // Keep auto-discovered global extensions behind bundled plugins. - // Users can still intentionally override via plugins.load.paths (origin=config). - discoverInDirectory({ - dir: roots.global, - origin: "global", - ownershipUid: params.ownershipUid, - candidates: result.candidates, - diagnostics: result.diagnostics, - seen, - realpathCache, + const scopedResult = tracePluginLifecyclePhase( + "discovery scan", + () => { + const result = createDiscoveryResult(); + const seen = new Set(); + const realpathCache = new Map(); + const extra = params.extraPaths ?? []; + for (const extraPath of extra) { + if (typeof extraPath !== "string") { + continue; + } + const trimmed = extraPath.trim(); + if (!trimmed) { + continue; + } + const bundledAlias = resolvePackagedBundledLoadPathAlias({ + bundledRoot: roots.stock, + loadPath: resolveUserPath(trimmed, env), + }); + if (bundledAlias) { + result.diagnostics.push({ + level: "warn", + source: trimmed, + message: `ignored plugins.load.paths entry that points at OpenClaw's ${bundledAlias.kind} bundled plugin directory; remove this redundant path or run openclaw doctor --fix`, }); - return result; - }, - { scope: "shared" }, - ), - }); + continue; + } + discoverFromPath({ + rawPath: trimmed, + origin: "config", + ownershipUid: params.ownershipUid, + workspaceDir, + env, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + }); + } + const workspaceMatchesBundledRoot = resolvesToSameDirectory( + workspaceRoot, + roots.stock, + realpathCache, + ); + if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) { + // Keep workspace auto-discovery constrained to the OpenClaw extensions root. + // Recursively scanning the full workspace treats arbitrary project folders as + // plugin candidates and causes noisy "plugin manifest not found" validation failures. + discoverInDirectory({ + dir: roots.workspace, + origin: "workspace", + ownershipUid: params.ownershipUid, + workspaceDir: workspaceRoot, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + }); + } + return result; + }, + { scope: "scoped", extraPathCount: params.extraPaths?.length ?? 0 }, + ); + const sharedResult = tracePluginLifecyclePhase( + "discovery scan", + () => { + const result = createDiscoveryResult(); + const seen = new Set(); + const realpathCache = new Map(); + for (const sourceOverlayDir of listBundledSourceOverlayDirs({ + bundledRoot: roots.stock, + env, + })) { + discoverFromPath({ + rawPath: sourceOverlayDir, + origin: "bundled", + ownershipUid: params.ownershipUid, + workspaceDir, + env, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + }); + result.diagnostics.push({ + level: "warn", + source: sourceOverlayDir, + message: + "using bind-mounted bundled plugin source overlay; this source overrides the packaged dist bundle for the same plugin id", + }); + } + if (roots.stock) { + discoverInDirectory({ + dir: roots.stock, + origin: "bundled", + ownershipUid: params.ownershipUid, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + }); + } + // Keep auto-discovered global extensions behind bundled plugins. + // Users can still intentionally override via plugins.load.paths (origin=config). + discoverInDirectory({ + dir: roots.global, + origin: "global", + ownershipUid: params.ownershipUid, + candidates: result.candidates, + diagnostics: result.diagnostics, + seen, + realpathCache, + }); + return result; + }, + { scope: "shared" }, + ); const result = createDiscoveryResult(); const seenSources = new Set(); mergeDiscoveryResult(result, scopedResult, seenSources); diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index 693285e4981..765be1d46c1 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -133,6 +133,44 @@ describe("doctor-contract-registry getJiti", () => { } }); + it("reads doctor contracts from the current manifest registry on each call", () => { + const firstRoot = makeTempDir(); + const secondRoot = makeTempDir(); + fs.writeFileSync( + path.join(firstRoot, "doctor-contract-api.cjs"), + "module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'first'], message: 'first contract' }] };\n", + "utf-8", + ); + fs.writeFileSync( + path.join(secondRoot, "doctor-contract-api.cjs"), + "module.exports = { legacyConfigRules: [{ path: ['plugins', 'entries', 'second'], message: 'second contract' }] };\n", + "utf-8", + ); + mocks.loadPluginManifestRegistry + .mockReturnValueOnce({ + plugins: [{ id: "first-plugin", rootDir: firstRoot }], + diagnostics: [], + }) + .mockReturnValueOnce({ + plugins: [{ id: "second-plugin", rootDir: secondRoot }], + diagnostics: [], + }); + + expect(listPluginDoctorLegacyConfigRules({ workspaceDir: "/workspace", env: {} })).toEqual([ + { + path: ["plugins", "entries", "first"], + message: "first contract", + }, + ]); + expect(listPluginDoctorLegacyConfigRules({ workspaceDir: "/workspace", env: {} })).toEqual([ + { + path: ["plugins", "entries", "second"], + message: "second contract", + }, + ]); + expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledTimes(2); + }); + it("narrows touched-path doctor ids for scoped dry-run validation", () => { expect( collectRelevantDoctorPluginIdsForTouchedPaths({ diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 522bb5199a5..afd209e4b5a 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -8,7 +8,6 @@ import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-lo import type { PluginManifestRegistry } from "./manifest-registry.js"; import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; -import { resolvePluginCacheInputs, type PluginSourceRoots } from "./roots.js"; const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const; const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); @@ -39,8 +38,6 @@ type PluginDoctorContractEntry = { type PluginManifestRegistryRecord = PluginManifestRegistry["plugins"][number]; const jitiLoaders: PluginJitiLoaderCache = new Map(); -const doctorContractCache = new Map(); -const doctorContractRecordCache = new Map>(); function getJiti(modulePath: string) { return getCachedPluginJitiLoader({ @@ -58,38 +55,6 @@ function loadPluginDoctorContractModule(modulePath: string): PluginDoctorContrac return getJiti(modulePath)(modulePath) as PluginDoctorContractModule; } -function buildDoctorContractCacheKey(params: { - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - pluginIds?: readonly string[]; -}): string { - return JSON.stringify({ - ...resolveDoctorContractBaseCachePayload(params), - pluginIds: [...(params.pluginIds ?? [])].toSorted(), - }); -} - -function buildDoctorContractBaseCacheKey(params: { - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): string { - return JSON.stringify(resolveDoctorContractBaseCachePayload(params)); -} - -function resolveDoctorContractBaseCachePayload(params: { - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): { - roots: PluginSourceRoots; - loadPaths: string[]; -} { - const { roots, loadPaths } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - env: params.env, - }); - return { roots, loadPaths }; -} - function resolveContractApiPath(rootDir: string): string | null { const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT ? CONTRACT_API_EXTENSIONS @@ -204,37 +169,17 @@ export function collectRelevantDoctorPluginIdsForTouchedPaths(params: { return [...ids].toSorted(); } -function getDoctorContractRecordCache( - baseCacheKey: string, -): Map { - let cache = doctorContractRecordCache.get(baseCacheKey); - if (!cache) { - cache = new Map(); - doctorContractRecordCache.set(baseCacheKey, cache); - } - return cache; -} - function loadPluginDoctorContractEntry( record: PluginManifestRegistryRecord, - baseCacheKey: string, ): PluginDoctorContractEntry | null { - const cache = getDoctorContractRecordCache(baseCacheKey); - const cached = cache.get(record.id); - if (cached !== undefined) { - return cached; - } - const contractSource = resolveContractApiPath(record.rootDir); if (!contractSource) { - cache.set(record.id, null); return null; } let mod: PluginDoctorContractModule; try { mod = loadPluginDoctorContractModule(contractSource); } catch { - cache.set(record.id, null); return null; } const rules = coerceLegacyConfigRules( @@ -246,16 +191,13 @@ function loadPluginDoctorContractEntry( (mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig, ); if (rules.length === 0 && !normalizeCompatibilityConfig) { - cache.set(record.id, null); return null; } - const entry = { + return { pluginId: record.id, rules, normalizeCompatibilityConfig, }; - cache.set(record.id, entry); - return entry; } function resolvePluginDoctorContracts(params?: { @@ -264,22 +206,7 @@ function resolvePluginDoctorContracts(params?: { pluginIds?: readonly string[]; }): PluginDoctorContractEntry[] { const env = params?.env ?? process.env; - const baseCacheKey = buildDoctorContractBaseCacheKey({ - workspaceDir: params?.workspaceDir, - env, - }); - const cacheKey = buildDoctorContractCacheKey({ - workspaceDir: params?.workspaceDir, - env, - pluginIds: params?.pluginIds, - }); - const cached = doctorContractCache.get(cacheKey); - if (cached) { - return cached; - } - if (params?.pluginIds && params.pluginIds.length === 0) { - doctorContractCache.set(cacheKey, []); return []; } @@ -301,19 +228,16 @@ function resolvePluginDoctorContracts(params?: { ) { continue; } - const entry = loadPluginDoctorContractEntry(record, baseCacheKey); + const entry = loadPluginDoctorContractEntry(record); if (entry) { entries.push(entry); } } - doctorContractCache.set(cacheKey, entries); return entries; } export function clearPluginDoctorContractRegistryCache(): void { - doctorContractCache.clear(); - doctorContractRecordCache.clear(); jitiLoaders.clear(); } diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index fb556707a74..ddfe4ff599d 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -169,8 +169,6 @@ describe("installed plugin index persistence", () => { const candidate = createCandidate(pluginDir); const env = { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }; @@ -267,8 +265,6 @@ describe("installed plugin index persistence", () => { candidates: [candidate], env: { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }, @@ -304,8 +300,6 @@ describe("installed plugin index persistence", () => { candidates: [], env: { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }, diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 20ec5b93caf..7d10e70f4ea 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -59,8 +59,6 @@ function writeManifestlessClaudeBundle(rootDir: string, entries: readonly string function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", ...overrides, diff --git a/src/plugins/manifest-model-id-normalization.test.ts b/src/plugins/manifest-model-id-normalization.test.ts new file mode 100644 index 00000000000..86fe448cbdf --- /dev/null +++ b/src/plugins/manifest-model-id-normalization.test.ts @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + clearManifestModelIdNormalizationCacheForTest, + normalizeProviderModelIdWithManifest, +} from "./manifest-model-id-normalization.js"; + +const ORIGINAL_ENV = { + OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, + OPENCLAW_HOME: process.env.OPENCLAW_HOME, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS, + OPENCLAW_BUNDLED_PLUGINS_DIR: process.env.OPENCLAW_BUNDLED_PLUGINS_DIR, +} as const; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-id-normalization-")); + tempDirs.push(dir); + return dir; +} + +function restoreEnv(): void { + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function writeInstallIndex(params: { stateDir: string; pluginDir: string }): void { + const indexPath = path.join(params.stateDir, "plugins", "installs.json"); + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync( + indexPath, + JSON.stringify({ + plugins: [ + { + id: "normalizer", + rootDir: params.pluginDir, + origin: "global", + }, + ], + }), + "utf-8", + ); +} + +function writeNormalizerManifest(params: { pluginDir: string; prefix: string }): void { + fs.mkdirSync(params.pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(params.pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "normalizer", + modelIdNormalization: { + providers: { + demo: { + prefixWhenBare: params.prefix, + }, + }, + }, + }), + "utf-8", + ); +} + +function normalizeDemoModel(modelId = "demo-model"): string | undefined { + return normalizeProviderModelIdWithManifest({ + provider: "demo", + context: { provider: "demo", modelId }, + }); +} + +describe("manifest model id normalization", () => { + afterEach(() => { + clearManifestModelIdNormalizationCacheForTest(); + restoreEnv(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("reflects manifest edits and state-dir changes on the next lookup", () => { + const stateDirA = makeTempDir(); + const pluginDirA = path.join(stateDirA, "extensions", "normalizer"); + writeInstallIndex({ stateDir: stateDirA, pluginDir: pluginDirA }); + writeNormalizerManifest({ pluginDir: pluginDirA, prefix: "alpha" }); + + process.env.OPENCLAW_STATE_DIR = stateDirA; + process.env.OPENCLAW_HOME = undefined; + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1"; + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = undefined; + + expect(normalizeDemoModel()).toBe("alpha/demo-model"); + + writeNormalizerManifest({ pluginDir: pluginDirA, prefix: "bravo" }); + expect(normalizeDemoModel()).toBe("bravo/demo-model"); + + const stateDirB = makeTempDir(); + const pluginDirB = path.join(stateDirB, "extensions", "normalizer"); + writeInstallIndex({ stateDir: stateDirB, pluginDir: pluginDirB }); + writeNormalizerManifest({ pluginDir: pluginDirB, prefix: "charlie" }); + + process.env.OPENCLAW_STATE_DIR = stateDirB; + expect(normalizeDemoModel()).toBe("charlie/demo-model"); + }); +}); diff --git a/src/plugins/manifest-model-id-normalization.ts b/src/plugins/manifest-model-id-normalization.ts index 1edf5aabcf5..0dfe97a8e3f 100644 --- a/src/plugins/manifest-model-id-normalization.ts +++ b/src/plugins/manifest-model-id-normalization.ts @@ -2,10 +2,6 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js"; import type { PluginManifestModelIdNormalizationProvider } from "./manifest.js"; -let manifestModelIdNormalizationCache: - | Map - | undefined; - function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -114,13 +110,7 @@ function loadManifestModelIdNormalizationPolicies(): Map< string, PluginManifestModelIdNormalizationProvider > { - if (manifestModelIdNormalizationCache) { - return manifestModelIdNormalizationCache; - } - - const policies = collectManifestModelIdNormalizationPolicies(); - manifestModelIdNormalizationCache = policies; - return policies; + return collectManifestModelIdNormalizationPolicies(); } function resolveManifestModelIdNormalizationPolicy( @@ -180,5 +170,6 @@ export function normalizeProviderModelIdWithManifest(params: { } export function clearManifestModelIdNormalizationCacheForTest(): void { - manifestModelIdNormalizationCache = undefined; + // Manifest model-id normalization reads are intentionally uncached. + // Keep the test reset hook as a compatibility no-op. } diff --git a/src/plugins/manifest-model-suppression.test.ts b/src/plugins/manifest-model-suppression.test.ts index 278ccb0098e..a755c4c3288 100644 --- a/src/plugins/manifest-model-suppression.test.ts +++ b/src/plugins/manifest-model-suppression.test.ts @@ -70,7 +70,7 @@ describe("manifest model suppression", () => { ).toBeUndefined(); }); - it("caches planned manifest suppressions per config and environment", () => { + it("reads planned manifest suppressions fresh per lookup", () => { const config = { plugins: { entries: { openai: { enabled: true } } } }; resolveManifestBuiltInModelSuppression({ @@ -86,7 +86,7 @@ describe("manifest model suppression", () => { env: process.env, }); - expect(mocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(1); + expect(mocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(2); }); it("matches conditional suppressions by base URL host", () => { diff --git a/src/plugins/manifest-model-suppression.ts b/src/plugins/manifest-model-suppression.ts index ce56195661d..98da5285aae 100644 --- a/src/plugins/manifest-model-suppression.ts +++ b/src/plugins/manifest-model-suppression.ts @@ -7,64 +7,17 @@ import { import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; -type ManifestSuppressionCache = Map; - -let cacheWithoutConfig = new WeakMap(); -let cacheByConfig = new WeakMap< - OpenClawConfig, - WeakMap ->(); - -function resolveSuppressionCache(params: { - config?: OpenClawConfig; - env: NodeJS.ProcessEnv; -}): ManifestSuppressionCache { - if (!params.config) { - let cache = cacheWithoutConfig.get(params.env); - if (!cache) { - cache = new Map(); - cacheWithoutConfig.set(params.env, cache); - } - return cache; - } - let envCaches = cacheByConfig.get(params.config); - if (!envCaches) { - envCaches = new WeakMap(); - cacheByConfig.set(params.config, envCaches); - } - let cache = envCaches.get(params.env); - if (!cache) { - cache = new Map(); - envCaches.set(params.env, cache); - } - return cache; -} - -function cacheKey(params: { workspaceDir?: string }): string { - return params.workspaceDir ?? ""; -} - function listManifestModelCatalogSuppressions(params: { config?: OpenClawConfig; workspaceDir?: string; env: NodeJS.ProcessEnv; }): readonly ManifestModelCatalogSuppressionEntry[] { - const cache = resolveSuppressionCache({ - config: params.config, - env: params.env, - }); - const key = cacheKey(params); - const cached = cache.get(key); - if (cached) { - return cached; - } const registry = loadPluginManifestRegistryForPluginRegistry({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); const planned = planManifestModelCatalogSuppressions({ registry }); - cache.set(key, planned.suppressions); return planned.suppressions; } @@ -147,11 +100,7 @@ function manifestSuppressionMatchesConditions(params: { } export function clearManifestModelSuppressionCacheForTest(): void { - cacheWithoutConfig = new WeakMap(); - cacheByConfig = new WeakMap< - OpenClawConfig, - WeakMap - >(); + // Manifest suppressions are read fresh. Keep the test hook as a no-op. } export function resolveManifestBuiltInModelSuppression(params: { diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts index e7378d7592d..7adf784d9fe 100644 --- a/src/plugins/manifest-registry-installed.test.ts +++ b/src/plugins/manifest-registry-installed.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { readPersistedInstalledPluginIndex, writePersistedInstalledPluginIndex, @@ -16,7 +16,6 @@ const tempDirs: string[] = []; afterEach(() => { clearInstalledManifestRegistryCache(); - vi.restoreAllMocks(); cleanupTrackedTempDirs(tempDirs); }); @@ -76,33 +75,7 @@ function createIndex(rootDir: string): InstalledPluginIndex { } describe("loadPluginManifestRegistryForInstalledIndex", () => { - it("reuses installed-index manifest registries for identical runtime lookups", () => { - const rootDir = makeTempDir(); - writePlugin(rootDir, "installed", "installed-"); - const index = createIndex(rootDir); - const readFileSync = vi.spyOn(fs, "readFileSync"); - const env = { - OPENCLAW_VERSION: "2026.4.25", - VITEST: "true", - }; - - const first = loadPluginManifestRegistryForInstalledIndex({ - index, - env, - includeDisabled: true, - }); - const readsAfterFirstLoad = readFileSync.mock.calls.length; - const second = loadPluginManifestRegistryForInstalledIndex({ - index, - env, - includeDisabled: true, - }); - - expect(second).toBe(first); - expect(readFileSync.mock.calls.length).toBe(readsAfterFirstLoad); - }); - - it("refreshes the installed-index manifest registry cache when manifest files change", () => { + it("reconstructs installed-index manifest registries when manifest files change", () => { const rootDir = makeTempDir(); const manifestPath = path.join(rootDir, "openclaw.plugin.json"); writePlugin(rootDir, "installed", "installed-"); @@ -137,33 +110,6 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { }); }); - it("bypasses the installed-index manifest registry cache when disabled", () => { - const rootDir = makeTempDir(); - writePlugin(rootDir, "installed", "installed-"); - const index = createIndex(rootDir); - const readFileSync = vi.spyOn(fs, "readFileSync"); - const env = { - OPENCLAW_DISABLE_INSTALLED_PLUGIN_MANIFEST_REGISTRY_CACHE: "1", - OPENCLAW_VERSION: "2026.4.25", - VITEST: "true", - }; - - const first = loadPluginManifestRegistryForInstalledIndex({ - index, - env, - includeDisabled: true, - }); - const readsAfterFirstLoad = readFileSync.mock.calls.length; - const second = loadPluginManifestRegistryForInstalledIndex({ - index, - env, - includeDisabled: true, - }); - - expect(second).not.toBe(first); - expect(readFileSync.mock.calls.length).toBeGreaterThan(readsAfterFirstLoad); - }); - it("loads manifest metadata only for plugins present in the installed index", () => { const installedRoot = makeTempDir(); const unrelatedRoot = makeTempDir(); @@ -173,8 +119,6 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { const registry = loadPluginManifestRegistryForInstalledIndex({ index: createIndex(installedRoot), env: { - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }, @@ -216,8 +160,6 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { ], }, env: { - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }, @@ -274,8 +216,6 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { ], }, env: { - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }, @@ -337,8 +277,6 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { const registry = loadPluginManifestRegistryForInstalledIndex({ index: persisted, env: { - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }, diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 94ab4301e17..bc496f8d475 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -3,7 +3,6 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginCandidate } from "./discovery.js"; import { hashJson } from "./installed-plugin-index-hash.js"; -import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; @@ -16,26 +15,6 @@ import { } from "./manifest.js"; import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; -const INSTALLED_MANIFEST_REGISTRY_FALLBACK_CACHE_MAX_ENTRIES = 64; - -type InstalledManifestRegistryCacheEntry = { - registry: PluginManifestRegistry; - lastUsed: number; -}; - -const installedManifestRegistryFallbackCache = new Map< - string, - InstalledManifestRegistryCacheEntry ->(); -let installedManifestRegistryFallbackCacheTick = 0; - -function normalizePluginIdFilter(pluginIds: readonly string[] | undefined): string[] | undefined { - if (!pluginIds?.length) { - return undefined; - } - return [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)); -} - function resolvePackageJsonPath(record: InstalledPluginIndexRecord): string | undefined { if (!record.packageJson?.path) { return undefined; @@ -61,19 +40,6 @@ function safeFileSignature(filePath: string | undefined): string | undefined { } } -function shouldUseInstalledManifestRegistryCache(params: { - env: NodeJS.ProcessEnv; - bundledChannelConfigCollector?: BundledChannelConfigCollector; -}): boolean { - if (params.bundledChannelConfigCollector) { - return false; - } - if (params.env.OPENCLAW_DISABLE_INSTALLED_PLUGIN_MANIFEST_REGISTRY_CACHE?.trim()) { - return false; - } - return !params.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim(); -} - function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) { return { version: index.version, @@ -120,69 +86,9 @@ export function resolveInstalledManifestRegistryIndexFingerprint( return hashJson(buildInstalledManifestRegistryIndexKey(index)); } -function buildInstalledManifestRegistryCacheKey(params: { - index: InstalledPluginIndex; - config?: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - pluginIds?: readonly string[]; - includeDisabled?: boolean; -}): string { - return hashJson({ - index: buildInstalledManifestRegistryIndexKey(params.index), - request: { - workspaceDir: params.workspaceDir, - pluginIds: normalizePluginIdFilter(params.pluginIds), - includeDisabled: params.includeDisabled === true, - configPolicyHash: resolveInstalledPluginIndexPolicyHash(params.config), - env: { - OPENCLAW_VERSION: params.env.OPENCLAW_VERSION, - HOME: params.env.HOME, - USERPROFILE: params.env.USERPROFILE, - }, - }, - }); -} - -function getCachedInstalledManifestRegistry(cacheKey: string): PluginManifestRegistry | undefined { - const cached = installedManifestRegistryFallbackCache.get(cacheKey); - if (!cached) { - return undefined; - } - cached.lastUsed = ++installedManifestRegistryFallbackCacheTick; - return cached.registry; -} - -function setCachedInstalledManifestRegistry( - cacheKey: string, - registry: PluginManifestRegistry, -): void { - if ( - !installedManifestRegistryFallbackCache.has(cacheKey) && - installedManifestRegistryFallbackCache.size >= - INSTALLED_MANIFEST_REGISTRY_FALLBACK_CACHE_MAX_ENTRIES - ) { - let oldestKey: string | undefined; - let oldestTick = Number.POSITIVE_INFINITY; - for (const [key, entry] of installedManifestRegistryFallbackCache) { - if (entry.lastUsed < oldestTick) { - oldestKey = key; - oldestTick = entry.lastUsed; - } - } - if (oldestKey) { - installedManifestRegistryFallbackCache.delete(oldestKey); - } - } - installedManifestRegistryFallbackCache.set(cacheKey, { - registry, - lastUsed: ++installedManifestRegistryFallbackCacheTick, - }); -} - export function clearInstalledManifestRegistryCache(): void { - installedManifestRegistryFallbackCache.clear(); - installedManifestRegistryFallbackCacheTick = 0; + // Installed-index manifest registries are reconstructed on demand. Keep this + // reset hook as a compatibility no-op for older tests and callers. } function resolveInstalledPluginRootDir(record: InstalledPluginIndexRecord): string { @@ -270,25 +176,6 @@ export function loadPluginManifestRegistryForInstalledIndex(params: { return { plugins: [], diagnostics: [] }; } const env = params.env ?? process.env; - const cacheKey = shouldUseInstalledManifestRegistryCache({ - env, - bundledChannelConfigCollector: params.bundledChannelConfigCollector, - }) - ? buildInstalledManifestRegistryCacheKey({ - index: params.index, - config: params.config, - workspaceDir: params.workspaceDir, - env, - pluginIds: params.pluginIds, - includeDisabled: params.includeDisabled, - }) - : undefined; - if (cacheKey) { - const cached = getCachedInstalledManifestRegistry(cacheKey); - if (cached) { - return cached; - } - } const pluginIdSet = params.pluginIds?.length ? new Set(params.pluginIds) : null; const diagnostics = pluginIdSet ? params.index.diagnostics.filter((diagnostic) => { @@ -300,7 +187,7 @@ export function loadPluginManifestRegistryForInstalledIndex(params: { .filter((plugin) => params.includeDisabled || plugin.enabled) .filter((plugin) => !pluginIdSet || pluginIdSet.has(plugin.pluginId)) .map(toPluginCandidate); - const registry = loadPluginManifestRegistry({ + return loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, env, @@ -312,10 +199,6 @@ export function loadPluginManifestRegistryForInstalledIndex(params: { ? { bundledChannelConfigCollector: params.bundledChannelConfigCollector } : {}), }); - if (cacheKey) { - setCachedInstalledManifestRegistry(cacheKey, registry); - } - return registry; }, { includeDisabled: params.includeDisabled === true, diff --git a/src/plugins/manifest-registry-state.ts b/src/plugins/manifest-registry-state.ts index 3cb9115a5a7..699bf6a3b0a 100644 --- a/src/plugins/manifest-registry-state.ts +++ b/src/plugins/manifest-registry-state.ts @@ -1,10 +1,4 @@ -export type PluginManifestRegistryCacheEntry = { - expiresAt: number; - registry: unknown; -}; - -export const pluginManifestRegistryCache = new Map(); - export function clearPluginManifestRegistryCache(): void { - pluginManifestRegistryCache.clear(); + // Manifest registry loads are intentionally uncached. Keep this legacy hook + // as a compatibility no-op for tests and older reset call sites. } diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 66ddc004b31..50b13198fb3 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -94,7 +94,6 @@ function loadRegistry(candidates: PluginCandidate[]) { function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", OPENCLAW_VERSION: undefined, VITEST: "true", ...overrides, @@ -312,6 +311,44 @@ afterEach(() => { }); describe("loadPluginManifestRegistry", () => { + it("reflects plugin manifest changes on the next registry load", () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "cached-manifest"); + mkdirSafe(pluginDir); + fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default function () {}", "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/cached-manifest", + openclaw: { extensions: ["./index.ts"] }, + }), + "utf-8", + ); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + writeManifest(pluginDir, { + id: "cached-manifest", + name: "Before", + configSchema: { type: "object" }, + }); + const env = hermeticEnv({ + OPENCLAW_STATE_DIR: stateDir, + }); + + const first = loadPluginManifestRegistry({ env }); + expect(first.plugins.find((plugin) => plugin.id === "cached-manifest")?.name).toBe("Before"); + + writeManifest(pluginDir, { + id: "cached-manifest", + name: "After", + configSchema: { type: "object" }, + }); + const updatedAt = new Date(Date.now() + 5000); + fs.utimesSync(manifestPath, updatedAt, updatedAt); + + const second = loadPluginManifestRegistry({ env }); + expect(second.plugins.find((plugin) => plugin.id === "cached-manifest")?.name).toBe("After"); + }); + it("keeps only the higher-precedence plugin for truly distinct duplicates", () => { const dirA = makeTempDir(); const dirB = makeTempDir(); @@ -1752,45 +1789,7 @@ describe("loadPluginManifestRegistry", () => { expect(hasUnsafeManifestDiagnostic(registry)).toBe(false); }); - it("does not reuse cached bundled plugin roots across env changes", () => { - const bundledA = makeTempDir(); - const bundledB = makeTempDir(); - const matrixA = createManifestPluginRoot({ - baseDir: bundledA, - pluginId: "matrix", - name: "Matrix A", - relativePath: "matrix", - }); - const matrixB = createManifestPluginRoot({ - baseDir: bundledB, - pluginId: "matrix", - name: "Matrix B", - relativePath: "matrix", - }); - - const first = loadPluginManifestRegistry({ - cache: true, - env: hermeticEnv({ - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledA, - }), - }); - const second = loadPluginManifestRegistry({ - cache: true, - env: hermeticEnv({ - OPENCLAW_BUNDLED_PLUGINS_DIR: bundledB, - }), - }); - - expectCachedPluginRoot({ - first, - second, - pluginId: "matrix", - firstRoot: matrixA, - secondRoot: matrixB, - }); - }); - - it("does not reuse cached load-path manifests across env home changes", () => { + it("resolves load-path manifests from the current env home", () => { const homeA = makeTempDir(); const homeB = makeTempDir(); const demoA = createManifestPluginRoot({ @@ -1842,7 +1841,7 @@ describe("loadPluginManifestRegistry", () => { }); }); - it("does not reuse cached manifests across host version changes", () => { + it("resolves manifests against the current host version", () => { const dir = makeTempDir(); writeManifest(dir, { id: "synology-chat", configSchema: { type: "object" } }); fs.writeFileSync(path.join(dir, "index.ts"), "export default {}", "utf-8"); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 5a3f2ae785b..c1e91c0f8f5 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -9,17 +9,10 @@ import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { loadBundleManifest } from "./bundle-manifest.js"; -import { - normalizePluginsConfigWithResolver, - type NormalizedPluginsConfig, -} from "./config-policy.js"; +import { normalizePluginsConfigWithResolver } from "./config-policy.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; -import { - clearPluginManifestRegistryCache, - pluginManifestRegistryCache, -} from "./manifest-registry-state.js"; import type { PluginBundleFormat, PluginConfigUiHint, @@ -49,7 +42,6 @@ import { checkMinHostVersion } from "./min-host-version.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginKind } from "./plugin-kind.types.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; -import { resolvePluginCacheInputs } from "./roots.js"; /** * Resolve a plugin source path, falling back from .ts to .js when the @@ -171,58 +163,8 @@ export type BundledChannelConfigCollector = (params: { packageManifest?: OpenClawPackageManifest; }) => Record | undefined; -const registryCache = pluginManifestRegistryCache as Map< - string, - { expiresAt: number; registry: PluginManifestRegistry } ->; - -// Keep a short cache window to collapse bursty reloads during startup flows. -const DEFAULT_MANIFEST_CACHE_MS = 1000; - export { clearPluginManifestRegistryCache } from "./manifest-registry-state.js"; -function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number { - const raw = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); - if (raw === "" || raw === "0") { - return 0; - } - if (!raw) { - return DEFAULT_MANIFEST_CACHE_MS; - } - const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed)) { - return DEFAULT_MANIFEST_CACHE_MS; - } - return Math.max(0, parsed); -} - -function shouldUseManifestCache(env: NodeJS.ProcessEnv): boolean { - const disabled = env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim(); - if (disabled) { - return false; - } - return resolveManifestCacheMs(env) > 0; -} - -function buildCacheKey(params: { - workspaceDir?: string; - plugins: NormalizedPluginsConfig; - env: NodeJS.ProcessEnv; -}): string { - const { roots, loadPaths } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - loadPaths: params.plugins.loadPaths, - env: params.env, - }); - const workspaceKey = roots.workspace ?? ""; - const configExtensionsRoot = roots.global; - const bundledRoot = roots.stock ?? ""; - const runtimeServiceVersion = resolveCompatibilityHostVersion(params.env); - // The manifest registry only depends on where plugins are discovered from (workspace + load paths). - // It does not depend on allow/deny/entries enable-state, so exclude those for higher cache hit rates. - return `${workspaceKey}::${configExtensionsRoot}::${bundledRoot}::${runtimeServiceVersion}::${JSON.stringify(loadPaths)}`; -} - function safeStatMtimeMs(filePath: string): number | null { try { return fs.statSync(filePath).mtimeMs; @@ -601,18 +543,6 @@ export function loadPluginManifestRegistry( const config = params.config ?? {}; const normalized = normalizePluginsConfigWithResolver(config.plugins); const env = params.env ?? process.env; - const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized, env }); - const cacheEnabled = - params.cache !== false && - !params.installRecords && - !params.bundledChannelConfigCollector && - shouldUseManifestCache(env); - if (cacheEnabled) { - const cached = registryCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.registry; - } - } const discovery = params.candidates ? { @@ -795,11 +725,5 @@ export function loadPluginManifestRegistry( } const registry = { plugins: records, diagnostics }; - if (cacheEnabled) { - const ttl = resolveManifestCacheMs(env); - if (ttl > 0) { - registryCache.set(cacheKey, { expiresAt: Date.now() + ttl, registry }); - } - } return registry; } diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index ac7ae035379..0ef2b65bd74 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -19,8 +19,6 @@ function makeTempDir() { function createHermeticEnv(rootDir: string): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(rootDir, "bundled"), - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.26", VITEST: "true", }; diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index e8f68bd02f7..d694d52237a 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -1,13 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; -import { normalizePluginsConfig } from "./config-state.js"; import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js"; import { inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndexSync, - resolveInstalledPluginIndexStorePath, refreshPersistedInstalledPluginIndex, type InstalledPluginIndexStoreInspection, type InstalledPluginIndexStoreOptions, @@ -24,7 +21,6 @@ import { type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, } from "./installed-plugin-index.js"; -import { resolvePluginCacheInputs } from "./roots.js"; export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; @@ -48,14 +44,9 @@ export type PluginRegistrySnapshotResult = { diagnostics: readonly PluginRegistrySnapshotDiagnostic[]; }; -const DERIVED_SNAPSHOT_CACHE_MS = 1000; -const derivedSnapshotCache = new Map< - string, - { expiresAt: number; result: PluginRegistrySnapshotResult } ->(); - export function clearPluginRegistrySnapshotCache(): void { - derivedSnapshotCache.clear(); + // Derived plugin registry snapshots are intentionally uncached. Keep the + // reset hook as a compatibility no-op for older callers. } export const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY"; @@ -123,40 +114,6 @@ function hasMismatchedPersistedBundledPluginRoot( ); } -function resolveDerivedSnapshotCacheKey( - params: LoadPluginRegistryParams, - env: NodeJS.ProcessEnv, -): string | null { - if ( - params.cache === false || - params.preferPersisted === false || - params.pluginIndexFilePath || - params.installRecords || - params.candidates || - params.diagnostics || - params.now - ) { - return null; - } - - const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); - const { roots, loadPaths } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - loadPaths: normalizedPlugins.loadPaths, - env, - }); - return JSON.stringify({ - persistedStore: resolveInstalledPluginIndexStorePath(params), - roots, - loadPaths, - policyHash: resolveInstalledPluginIndexPolicyHash(params.config), - hostContractVersion: resolveCompatibilityHostVersion(env), - disablePersisted: env[DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV] ?? "", - disableBundled: env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ?? "", - vitest: env.VITEST ?? "", - }); -} - export function loadPluginRegistrySnapshotWithMetadata( params: LoadPluginRegistryParams = {}, ): PluginRegistrySnapshotResult { @@ -174,15 +131,6 @@ export function loadPluginRegistrySnapshotWithMetadata( const disabledByEnv = hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV); const persistedReadsEnabled = !disabledByCaller && !disabledByEnv; const persistedInstallRecordReadsEnabled = !disabledByEnv; - const derivedCacheKey = persistedReadsEnabled - ? resolveDerivedSnapshotCacheKey(params, env) - : null; - if (derivedCacheKey) { - const cached = derivedSnapshotCache.get(derivedCacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.result; - } - } let persistedIndex: InstalledPluginIndex | null = null; if (persistedInstallRecordReadsEnabled) { persistedIndex = readPersistedInstalledPluginIndexSync(params); @@ -235,7 +183,7 @@ export function loadPluginRegistrySnapshotWithMetadata( }); } - const result: PluginRegistrySnapshotResult = { + return { snapshot: loadInstalledPluginIndex({ ...params, installRecords: @@ -245,13 +193,6 @@ export function loadPluginRegistrySnapshotWithMetadata( source: "derived", diagnostics, }; - if (derivedCacheKey) { - derivedSnapshotCache.set(derivedCacheKey, { - expiresAt: Date.now() + DERIVED_SNAPSHOT_CACHE_MS, - result, - }); - } - return result; } function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot { diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 92a5dfea82b..91a48708ab9 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -47,8 +47,6 @@ function makeTempDir() { function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", ...overrides, diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 30d3e7cbb8e..239debee82c 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -347,10 +347,8 @@ export async function applyAuthChoiceLoadedPluginProvider( }); } if (!resolved && installCatalogEntry) { - const [{ ensureOnboardingPluginInstalled }, { clearPluginDiscoveryCache }] = await Promise.all([ - import("../commands/onboarding-plugin-install.js"), - import("./discovery.js"), - ]); + const { ensureOnboardingPluginInstalled } = + await import("../commands/onboarding-plugin-install.js"); const installResult = await ensureOnboardingPluginInstalled({ cfg: nextConfig, entry: { @@ -366,7 +364,6 @@ export async function applyAuthChoiceLoadedPluginProvider( return { config: installResult.cfg, retrySelection: true }; } nextConfig = installResult.cfg; - clearPluginDiscoveryCache(); providers = resolveScopedRuntimeProviders(nextConfig); resolved = resolveProviderPluginChoice({ providers, diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 23aaf1f9201..89f20c21104 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -27,8 +27,6 @@ function makeTempDir() { function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", ...overrides, diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index 6c2dbc7acf5..d072c54f6c2 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -1,11 +1,8 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js"; import { resolveProviderConfigApiOwnerHint } from "./provider-config-owner.js"; -import { resolveOwningPluginIdsForProvider } from "./providers.js"; import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js"; -import { resolvePluginCacheInputs } from "./roots.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import type { ProviderPlugin, @@ -35,151 +32,16 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string): return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized; } -let cachedHookProviders = new WeakMap>(); - -function resolveHookProviderCacheBucket(env: NodeJS.ProcessEnv) { - let bucket = cachedHookProviders.get(env); - if (!bucket) { - bucket = new Map(); - cachedHookProviders.set(env, bucket); - } - return bucket; -} - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function projectPluginEntryForProviderHookCache( - pluginId: string, - entry: unknown, - fullConfigPluginIds: ReadonlySet, -): unknown { - if (!isRecord(entry) || fullConfigPluginIds.has(pluginId)) { - return entry; - } - const { - config: _config, - hooks: _hooks, - subagent: _subagent, - apiKey: _apiKey, - env: _env, - ...rest - } = entry; - return rest; -} - -function projectPluginsConfigForProviderHookCache( - plugins: OpenClawConfig["plugins"], - fullConfigPluginIds: ReadonlySet, -): unknown { - if (!isRecord(plugins)) { - return plugins ?? null; - } - const entries = isRecord(plugins.entries) - ? Object.fromEntries( - Object.entries(plugins.entries) - .toSorted(([left], [right]) => left.localeCompare(right)) - .map(([pluginId, entry]) => [ - pluginId, - projectPluginEntryForProviderHookCache(pluginId, entry, fullConfigPluginIds), - ]), - ) - : plugins.entries; - return { - ...plugins, - entries, - }; -} - -function resolveProviderOwnerConfigPluginIds(params: { - providerRefs?: readonly string[]; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): string[] { - if (!params.providerRefs?.length) { - return []; - } - const pluginIds = new Set(); - for (const provider of params.providerRefs) { - for (const pluginId of resolveOwningPluginIdsForProvider({ - provider, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) ?? []) { - pluginIds.add(pluginId); - } - const apiOwnerHint = resolveProviderConfigApiOwnerHint({ - provider, - config: params.config, - }); - if (!apiOwnerHint) { - continue; - } - for (const pluginId of resolveOwningPluginIdsForProvider({ - provider: apiOwnerHint, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }) ?? []) { - pluginIds.add(pluginId); - } - } - return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); -} - -export function resolveProviderHookConfigCacheShape( - config: OpenClawConfig | undefined, - fullConfigPluginIds: readonly string[] | undefined, -): unknown { - if (!config) { - return null; - } - const fullConfigPluginIdSet = new Set(fullConfigPluginIds ?? []); - return { - plugins: projectPluginsConfigForProviderHookCache(config.plugins, fullConfigPluginIdSet), - }; -} - -function buildHookProviderCacheKey(params: { - config?: OpenClawConfig; - workspaceDir?: string; - onlyPluginIds?: string[]; - providerRefs?: string[]; - env?: NodeJS.ProcessEnv; - fullConfigPluginIds?: string[]; - applyAutoEnable?: boolean; - bundledProviderAllowlistCompat?: boolean; - bundledProviderVitestCompat?: boolean; - installBundledRuntimeDeps?: boolean; -}) { - const { roots } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - env: params.env, - }); - const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds); - const loadPolicy = { - applyAutoEnable: params.applyAutoEnable ?? true, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true, - bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, - installBundledRuntimeDeps: params.installBundledRuntimeDeps ?? false, - }; - return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveProviderHookConfigCacheShape(params.config, params.fullConfigPluginIds))}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}::${JSON.stringify(loadPolicy)}`; -} - export function clearProviderRuntimeHookCache(): void { - cachedHookProviders = new WeakMap>(); + // Provider hook lookup is intentionally uncached. Keep the reset hook as a + // compatibility no-op for callers that still clear plugin runtime state. } export function resetProviderRuntimeHookCacheForTest(): void { clearProviderRuntimeHookCache(); } -export const __testing = { - buildHookProviderCacheKey, -} as const; +export const __testing = {} as const; export function resolveProviderPluginsForHooks(params: { config?: OpenClawConfig; @@ -194,36 +56,6 @@ export function resolveProviderPluginsForHooks(params: { }): ProviderPlugin[] { const env = params.env ?? process.env; const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); - const cacheBucket = resolveHookProviderCacheBucket(env); - const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds); - const explicitPluginIds = onlyPluginIds ?? []; - const fullConfigPluginIds = [ - ...new Set([ - ...explicitPluginIds, - ...resolveProviderOwnerConfigPluginIds({ - providerRefs: params.providerRefs, - config: params.config, - workspaceDir, - env, - }), - ]), - ].toSorted((left, right) => left.localeCompare(right)); - const cacheKey = buildHookProviderCacheKey({ - config: params.config, - workspaceDir, - onlyPluginIds, - providerRefs: params.providerRefs, - env, - fullConfigPluginIds, - applyAutoEnable: params.applyAutoEnable, - bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat, - bundledProviderVitestCompat: params.bundledProviderVitestCompat, - installBundledRuntimeDeps: params.installBundledRuntimeDeps, - }); - const cached = cacheBucket.get(cacheKey); - if (cached) { - return cached; - } if ( isPluginProvidersLoadInFlight({ ...params, @@ -250,7 +82,6 @@ export function resolveProviderPluginsForHooks(params: { bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true, installBundledRuntimeDeps: params.installBundledRuntimeDeps, }); - cacheBucket.set(cacheKey, resolved); return resolved; } diff --git a/src/plugins/provider-public-artifacts.ts b/src/plugins/provider-public-artifacts.ts index 14cf81bb444..a3595a15b69 100644 --- a/src/plugins/provider-public-artifacts.ts +++ b/src/plugins/provider-public-artifacts.ts @@ -1,7 +1,6 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveBundledPluginsDir } from "./bundled-dir.js"; import type { ProviderApplyConfigDefaultsContext, ProviderNormalizeConfigContext, @@ -19,13 +18,6 @@ export type BundledProviderPolicySurface = { resolveConfigApiKey?: (ctx: ProviderResolveConfigApiKeyContext) => string | null | undefined; }; -const bundledProviderPolicySurfaceCache = new Map(); - -function buildProviderPolicySurfaceCacheKey(providerId: string): string { - const bundledPluginsDir = resolveBundledPluginsDir(); - return `${providerId}::${bundledPluginsDir ?? ""}`; -} - function hasProviderPolicyHook( mod: Record, ): mod is Record & BundledProviderPolicySurface { @@ -62,7 +54,8 @@ function tryLoadBundledProviderPolicySurface( } export function clearBundledProviderPolicySurfaceCache(): void { - bundledProviderPolicySurfaceCache.clear(); + // Public provider policy surfaces are resolved fresh. The underlying module + // loader owns import reuse. } export function resolveBundledProviderPolicySurface( @@ -72,17 +65,5 @@ export function resolveBundledProviderPolicySurface( if (!normalizedProviderId) { return null; } - const cacheKey = buildProviderPolicySurfaceCacheKey(normalizedProviderId); - if (bundledProviderPolicySurfaceCache.has(cacheKey)) { - return bundledProviderPolicySurfaceCache.get(cacheKey) ?? null; - } - - const surface = tryLoadBundledProviderPolicySurface(normalizedProviderId); - if (surface) { - bundledProviderPolicySurfaceCache.set(cacheKey, surface); - return surface; - } - - bundledProviderPolicySurfaceCache.set(cacheKey, null); - return null; + return tryLoadBundledProviderPolicySurface(normalizedProviderId); } diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 24a2b7c6444..1d5589565c3 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -372,115 +372,6 @@ describe("provider-runtime", () => { }); }); - it("normalizes plugin scopes in provider hook cache keys", () => { - const base = { - workspaceDir: "/tmp/workspace", - env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv, - providerRefs: ["demo"], - }; - - expect( - providerRuntimeTesting.buildHookProviderCacheKey({ - ...base, - onlyPluginIds: [" beta ", "alpha", "beta"], - }), - ).toBe( - providerRuntimeTesting.buildHookProviderCacheKey({ - ...base, - onlyPluginIds: ["alpha", "beta"], - }), - ); - }); - - it("separates provider hook cache keys by load policy", () => { - const base = { - workspaceDir: "/tmp/workspace", - env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv, - providerRefs: ["demo"], - }; - - expect( - providerRuntimeTesting.buildHookProviderCacheKey({ - ...base, - applyAutoEnable: false, - bundledProviderAllowlistCompat: false, - bundledProviderVitestCompat: false, - installBundledRuntimeDeps: false, - }), - ).not.toBe(providerRuntimeTesting.buildHookProviderCacheKey(base)); - }); - - it("ignores unrelated plugin config values in provider hook cache keys", () => { - const base = { - workspaceDir: "/tmp/workspace", - env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv, - onlyPluginIds: ["demo"], - }; - const firstConfig = { - plugins: { - entries: { - demo: { enabled: true, config: { endpoint: "https://demo.example" } }, - "active-memory": { enabled: true }, - }, - }, - } as OpenClawConfig; - const secondConfig = { - plugins: { - entries: { - demo: { enabled: true, config: { endpoint: "https://demo.example" } }, - "active-memory": { enabled: true, config: { qmd: { searchMode: "fast" } } }, - }, - }, - } as OpenClawConfig; - - expect( - providerRuntimeTesting.buildHookProviderCacheKey({ - ...base, - config: firstConfig, - fullConfigPluginIds: ["demo"], - }), - ).toBe( - providerRuntimeTesting.buildHookProviderCacheKey({ - ...base, - config: secondConfig, - fullConfigPluginIds: ["demo"], - }), - ); - }); - - it("keeps scoped provider plugin config in provider hook cache keys", () => { - const base = { - workspaceDir: "/tmp/workspace", - env: { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv, - onlyPluginIds: ["demo"], - fullConfigPluginIds: ["demo"], - }; - - expect( - providerRuntimeTesting.buildHookProviderCacheKey({ - ...base, - config: { - plugins: { - entries: { - demo: { enabled: true, config: { endpoint: "https://one.example" } }, - }, - }, - } as OpenClawConfig, - }), - ).not.toBe( - providerRuntimeTesting.buildHookProviderCacheKey({ - ...base, - config: { - plugins: { - entries: { - demo: { enabled: true, config: { endpoint: "https://two.example" } }, - }, - }, - } as OpenClawConfig, - }), - ); - }); - it("keeps provider-ref owner plugin config in provider hook cache keys", () => { const provider: ProviderPlugin = { id: DEMO_PROVIDER_ID, @@ -514,7 +405,7 @@ describe("provider-runtime", () => { expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); }); - it("reuses provider-ref hook loads when unrelated plugin config changes", () => { + it("resolves provider-ref hook loads from current config each time", () => { const provider: ProviderPlugin = { id: DEMO_PROVIDER_ID, label: "Demo", @@ -546,7 +437,7 @@ describe("provider-runtime", () => { provider, ); - expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); }); it("does not reuse auto-enabled runtime providers for synthetic auth fallback", () => { @@ -685,7 +576,7 @@ describe("provider-runtime", () => { expect(providerRuntimeWarnMock).not.toHaveBeenCalled(); }); - it("reuses catalog hook provider loads when only non-plugin config changes", async () => { + it("resolves catalog hook provider loads when only non-plugin config changes", async () => { resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]); resolvePluginProvidersMock.mockReturnValue([ { @@ -726,10 +617,10 @@ describe("provider-runtime", () => { }), ).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]); - expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); }); - it("reuses catalog hook provider loads when unrelated plugin config changes", async () => { + it("resolves catalog hook provider loads when unrelated plugin config changes", async () => { resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]); resolvePluginProvidersMock.mockReturnValue([ { @@ -766,8 +657,8 @@ describe("provider-runtime", () => { ).toEqual([{ provider: "demo", id: "demo-model", name: "Demo Model" }]); } - expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1); - expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); + expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(2); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2); }); it("returns provider-prepared runtime auth for the matched provider", async () => { @@ -2124,7 +2015,7 @@ describe("provider-runtime", () => { expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); }); - it("keeps cached provider hook results available during a nested provider load", () => { + it("does not reuse provider hook results during a nested provider load", () => { const cachedNormalizedConfig: ModelProviderConfig = { baseUrl: "https://cached.example.com", api: "openai-completions", @@ -2157,7 +2048,7 @@ describe("provider-runtime", () => { }, }, }); - expect(reentrantResult).toBe(cachedNormalizedConfig); + expect(reentrantResult).toBeUndefined(); return []; } finally { providerLoadInFlight = false; diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 943fa5c97b5..23ddfdad046 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -17,7 +17,6 @@ import { __testing as providerHookRuntimeTesting, clearProviderRuntimeHookCache as clearProviderHookRuntimeCache, prepareProviderExtraParams, - resolveProviderHookConfigCacheShape, resolveProviderAuthProfileId, resolveProviderExtraParamsForTransport, resolveProviderFollowupFallbackRoute, @@ -35,7 +34,6 @@ import { resolveExternalAuthProfileProviderPluginIds, resolveOwningPluginIdsForProvider, } from "./providers.js"; -import { resolvePluginCacheInputs } from "./roots.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; import type { @@ -86,8 +84,6 @@ import type { const log = createSubsystemLogger("plugins/provider-runtime"); const warnedExternalAuthFallbackPluginIds = new Set(); -let catalogHookProvidersCache = new WeakMap>(); -let catalogHookProviderIdCache = new WeakMap>(); function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); @@ -144,61 +140,12 @@ function resetExternalAuthFallbackWarningCacheForTest(): void { } function resetCatalogHookProvidersCacheForTest(): void { - catalogHookProvidersCache = new WeakMap>(); + // Catalog hook providers are intentionally resolved from current metadata on + // each call. Keep the test hook as a compatibility no-op. } function clearCatalogHookProviderIdCache(): void { - catalogHookProviderIdCache = new WeakMap>(); -} - -function resolveCatalogHookProviderIdCacheBucket(params: { - env: NodeJS.ProcessEnv; -}): Map { - let bucket = catalogHookProviderIdCache.get(params.env); - if (!bucket) { - bucket = new Map(); - catalogHookProviderIdCache.set(params.env, bucket); - } - return bucket; -} - -function buildCatalogHookProviderIdCacheKey(params: { - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): string { - const { roots } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - env: params.env, - }); - return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveProviderHookConfigCacheShape(params.config, undefined))}`; -} - -function resolveCachedCatalogHookProviderPluginIds(params: { - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): string[] { - const env = params.env ?? process.env; - const bucket = resolveCatalogHookProviderIdCacheBucket({ - env, - }); - const key = buildCatalogHookProviderIdCacheKey({ - config: params.config, - workspaceDir: params.workspaceDir, - env, - }); - const cached = bucket.get(key); - if (cached) { - return cached; - } - const resolved = resolveCatalogHookProviderPluginIds({ - config: params.config, - workspaceDir: params.workspaceDir, - env, - }); - bucket.set(key, resolved); - return resolved; + // Catalog hook provider ids are intentionally uncached. } export function clearProviderRuntimeHookCache(): void { @@ -234,36 +181,20 @@ function resolveProviderPluginsForCatalogHooks(params: { }): ProviderPlugin[] { const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); const env = params.env ?? process.env; - let envCache = catalogHookProvidersCache.get(env); - if (!envCache) { - envCache = new Map(); - catalogHookProvidersCache.set(env, envCache); - } - const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({ + const onlyPluginIds = resolveCatalogHookProviderPluginIds({ config: params.config, workspaceDir, env, }); - const cacheKey = JSON.stringify({ - workspaceDir: workspaceDir ?? "", - plugins: resolveProviderHookConfigCacheShape(params.config, onlyPluginIds), - }); - const cached = envCache.get(cacheKey); - if (cached) { - return cached; - } if (onlyPluginIds.length === 0) { - envCache.set(cacheKey, []); return []; } - const providers = resolveProviderPluginsForHooks({ + return resolveProviderPluginsForHooks({ ...params, workspaceDir, env, onlyPluginIds, }); - envCache.set(cacheKey, providers); - return providers; } export function runProviderDynamicModel(params: { diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 53e3d43b836..0f9b2757ffa 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -320,6 +320,25 @@ describe("resolvePluginProviders", () => { expectOwningPluginIds("codex-cli", ["openai"]); }); + it("reflects provider ownership manifest changes on the next lookup", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "first-owner", + providerIds: ["dynamic-provider"], + }), + ]); + expectOwningPluginIds("dynamic-provider", ["first-owner"]); + + setManifestPlugins([ + createManifestProviderPlugin({ + id: "second-owner", + providerIds: ["dynamic-provider"], + }), + ]); + + expectOwningPluginIds("dynamic-provider", ["second-owner"]); + }); + beforeEach(() => { clearPluginRegistrySnapshotCache(); setActivePluginRegistry(createEmptyPluginRegistry()); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 0f4eb38fe8b..a56f1c35442 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -428,28 +428,8 @@ function dedupeSortedPluginIds(values: Iterable): string[] { return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); } -let owningProviderPluginIdsCache = new WeakMap< - NodeJS.ProcessEnv, - Map ->(); - -function buildOwningProviderPluginIdsCacheKey(params: { - provider: string; - config?: PluginLoadOptions["config"]; - workspaceDir?: string; -}): string { - return JSON.stringify({ - provider: normalizeProviderId(params.provider), - workspaceDir: params.workspaceDir ?? "", - plugins: params.config?.plugins ?? null, - }); -} - export function resetProviderOwnerPluginIdsCacheForTest(): void { - owningProviderPluginIdsCache = new WeakMap< - NodeJS.ProcessEnv, - Map - >(); + // Provider ownership is manifest-derived and intentionally read fresh. } function resolvePreferredManifestPluginIds( @@ -505,20 +485,6 @@ export function resolveOwningPluginIdsForProvider(params: { } const env = params.env ?? process.env; - let envCache = owningProviderPluginIdsCache.get(env); - if (!envCache) { - envCache = new Map(); - owningProviderPluginIdsCache.set(env, envCache); - } - const cacheKey = buildOwningProviderPluginIdsCacheKey({ - provider: normalizedProvider, - config: params.config, - workspaceDir: params.workspaceDir, - }); - if (envCache.has(cacheKey)) { - return envCache.get(cacheKey); - } - const pluginIds = [ ...resolveProviderOwners({ config: params.config, @@ -538,9 +504,7 @@ export function resolveOwningPluginIdsForProvider(params: { ]; const deduped = dedupeSortedPluginIds(pluginIds); - const resolved = deduped.length > 0 ? deduped : undefined; - envCache.set(cacheKey, resolved); - return resolved; + return deduped.length > 0 ? deduped : undefined; } export function resolveOwningPluginIdsForModelRef(params: { diff --git a/src/plugins/roots.ts b/src/plugins/roots.ts index 1b74f6c5d9b..4ec51d9051b 100644 --- a/src/plugins/roots.ts +++ b/src/plugins/roots.ts @@ -25,7 +25,7 @@ export function resolvePluginSourceRoots(params: { return { stock, global, workspace }; } -// Shared env-aware cache inputs for discovery, manifest, and loader caches. +// Shared env-aware key inputs for plugin loader registry reuse. export function resolvePluginCacheInputs(params: { workspaceDir?: string; loadPaths?: string[]; diff --git a/src/plugins/setup-registry.runtime.test.ts b/src/plugins/setup-registry.runtime.test.ts index 62eb8c1e356..189145db1a4 100644 --- a/src/plugins/setup-registry.runtime.test.ts +++ b/src/plugins/setup-registry.runtime.test.ts @@ -60,7 +60,7 @@ describe("setup-registry runtime fallback", () => { }); expect(resolvePluginSetupCliBackendRuntime({ backend: "local-cli" })).toBeUndefined(); expect(resolvePluginSetupCliBackendRuntime({ backend: "disabled-cli" })).toBeUndefined(); - expect(loadPluginRegistrySnapshotMock).toHaveBeenCalledTimes(1); + expect(loadPluginRegistrySnapshotMock).toHaveBeenCalledTimes(3); expect(loadPluginRegistrySnapshotMock).toHaveBeenCalledWith({ cache: true }); expect(loadPluginManifestRegistryForInstalledIndexMock).toHaveBeenCalledWith({ index: expect.objectContaining({ diff --git a/src/plugins/setup-registry.runtime.ts b/src/plugins/setup-registry.runtime.ts index ea014043ac6..591c130064b 100644 --- a/src/plugins/setup-registry.runtime.ts +++ b/src/plugins/setup-registry.runtime.ts @@ -19,12 +19,10 @@ const require = createRequire(import.meta.url); const SETUP_REGISTRY_RUNTIME_CANDIDATES = ["./setup-registry.js", "./setup-registry.ts"] as const; let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | null | undefined; -let bundledSetupCliBackendsCache: SetupCliBackendRuntimeEntry[] | undefined; export const __testing = { resetRuntimeState(): void { setupRegistryRuntimeModule = undefined; - bundledSetupCliBackendsCache = undefined; }, setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | null | undefined): void { setupRegistryRuntimeModule = module; @@ -32,11 +30,8 @@ export const __testing = { }; function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] { - if (bundledSetupCliBackendsCache) { - return bundledSetupCliBackendsCache; - } const index = loadPluginRegistrySnapshot({ cache: true }); - bundledSetupCliBackendsCache = loadPluginManifestRegistryForInstalledIndex({ + return loadPluginManifestRegistryForInstalledIndex({ index, }).plugins.flatMap((plugin) => { if (plugin.origin !== "bundled") { @@ -50,7 +45,6 @@ function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] { }) satisfies SetupCliBackendRuntimeEntry, ); }); - return bundledSetupCliBackendsCache; } function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null { diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index 26f8c79156d..6189c692c88 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -772,10 +772,9 @@ describe("setup-registry getJiti", () => { expect(mocks.createJiti).not.toHaveBeenCalled(); }); - it("bounds setup lookup caches with least-recently-used eviction", () => { + it("does not retain setup lookup cache entries", () => { const pluginRoot = makeTempDir(); fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); - setupRegistryTesting.setMaxSetupLookupCacheEntriesForTest(1); mocks.loadPluginManifestRegistry.mockReturnValue({ plugins: [ { @@ -807,7 +806,7 @@ describe("setup-registry getJiti", () => { expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai"); expect(resolvePluginSetupProvider({ provider: "anthropic", env: {} })?.id).toBe("anthropic"); - expect(setupRegistryTesting.getCacheSizes().setupProvider).toBe(1); + expect(setupRegistryTesting.getCacheSizes().setupProvider).toBe(0); expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai"); expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe( @@ -816,7 +815,7 @@ describe("setup-registry getJiti", () => { expect(resolvePluginSetupCliBackend({ backend: "claude-cli", env: {} })?.backend.id).toBe( "claude-cli", ); - expect(setupRegistryTesting.getCacheSizes().setupCliBackend).toBe(1); + expect(setupRegistryTesting.getCacheSizes().setupCliBackend).toBe(0); expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe( "codex-cli", ); @@ -829,7 +828,7 @@ describe("setup-registry getJiti", () => { env: {}, pluginIds: ["anthropic"], }); - expect(setupRegistryTesting.getCacheSizes().setupRegistry).toBe(1); + expect(setupRegistryTesting.getCacheSizes().setupRegistry).toBe(0); expect(loadSetupModule).toHaveBeenCalledTimes(7); }); }); diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 9e4626a60c2..2fa71664002 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -7,9 +7,7 @@ import { buildPluginApi } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; -import { PluginLruCache, type PluginLruCacheResult } from "./plugin-lru-cache.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; -import { resolvePluginCacheInputs } from "./roots.js"; import type { PluginRuntime } from "./runtime/types.js"; import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js"; import type { @@ -84,40 +82,24 @@ const NOOP_LOGGER: PluginLogger = { error() {}, }; -const MAX_SETUP_LOOKUP_CACHE_ENTRIES = 128; - const jitiLoaders: PluginJitiLoaderCache = new Map(); -const setupRegistryCache = new PluginLruCache(MAX_SETUP_LOOKUP_CACHE_ENTRIES); -const setupProviderCache = new PluginLruCache( - MAX_SETUP_LOOKUP_CACHE_ENTRIES, -); -const setupCliBackendCache = new PluginLruCache( - MAX_SETUP_LOOKUP_CACHE_ENTRIES, -); export const __testing = { get maxSetupLookupCacheEntries() { - return setupRegistryCache.maxEntries; - }, - setMaxSetupLookupCacheEntriesForTest(value?: number) { - setupRegistryCache.setMaxEntriesForTest(value); - setupProviderCache.setMaxEntriesForTest(value); - setupCliBackendCache.setMaxEntriesForTest(value); + return 0; }, + setMaxSetupLookupCacheEntriesForTest(_value?: number) {}, getCacheSizes() { return { - setupRegistry: setupRegistryCache.size, - setupProvider: setupProviderCache.size, - setupCliBackend: setupCliBackendCache.size, + setupRegistry: 0, + setupProvider: 0, + setupCliBackend: 0, }; }, } as const; export function clearPluginSetupRegistryCache(): void { jitiLoaders.clear(); - setupRegistryCache.clear(); - setupProviderCache.clear(); - setupCliBackendCache.clear(); } function getJiti(modulePath: string) { @@ -128,58 +110,6 @@ function getJiti(modulePath: string) { }); } -function getCachedSetupValue(cache: PluginLruCache, key: string): PluginLruCacheResult { - return cache.getResult(key); -} - -function setCachedSetupValue(cache: PluginLruCache, key: string, value: T): void { - cache.set(key, value); -} - -function buildSetupRegistryCacheKey(params: { - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - pluginIds?: readonly string[]; -}): string { - const { roots, loadPaths } = resolvePluginCacheInputs({ - workspaceDir: params.workspaceDir, - env: params.env, - loadPaths: params.config?.plugins?.load?.paths, - }); - return JSON.stringify({ - roots, - loadPaths, - hasConfig: Boolean(params.config), - pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null, - }); -} - -function buildSetupProviderCacheKey(params: { - provider: string; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - pluginIds?: readonly string[]; -}): string { - return JSON.stringify({ - provider: normalizeProviderId(params.provider), - registry: buildSetupRegistryCacheKey(params), - }); -} - -function buildSetupCliBackendCacheKey(params: { - backend: string; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): string { - return JSON.stringify({ - backend: normalizeProviderId(params.backend), - registry: buildSetupRegistryCacheKey(params), - }); -} - function resolveSetupApiPath( rootDir: string, options?: { includeBundledSourceFallback?: boolean }, @@ -489,17 +419,6 @@ export function resolvePluginSetupRegistry(params?: { pluginIds?: readonly string[]; }): PluginSetupRegistry { const env = params?.env ?? process.env; - const cacheKey = buildSetupRegistryCacheKey({ - config: params?.config, - workspaceDir: params?.workspaceDir, - env, - pluginIds: params?.pluginIds, - }); - const cached = getCachedSetupValue(setupRegistryCache, cacheKey); - if (cached.hit) { - return cached.value; - } - const selectedPluginIds = params?.pluginIds ? new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)) : null; @@ -511,7 +430,6 @@ export function resolvePluginSetupRegistry(params?: { autoEnableProbes: [], diagnostics: [], } satisfies PluginSetupRegistry; - setCachedSetupValue(setupRegistryCache, cacheKey, empty); return empty; } @@ -615,7 +533,6 @@ export function resolvePluginSetupRegistry(params?: { autoEnableProbes, diagnostics, } satisfies PluginSetupRegistry; - setCachedSetupValue(setupRegistryCache, cacheKey, registry); return registry; } @@ -626,12 +543,6 @@ export function resolvePluginSetupProvider(params: { env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): ProviderPlugin | undefined { - const cacheKey = buildSetupProviderCacheKey(params); - const cached = getCachedSetupValue(setupProviderCache, cacheKey); - if (cached.hit) { - return cached.value ?? undefined; - } - const env = params.env ?? process.env; const normalizedProvider = normalizeProviderId(params.provider); const manifestRegistry = loadSetupManifestRegistry({ @@ -646,13 +557,11 @@ export function resolvePluginSetupProvider(params: { listIds: listSetupProviderIds, }); if (!record) { - setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } const setupRegistration = resolveSetupRegistration(record); if (!setupRegistration) { - setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } @@ -684,11 +593,9 @@ export function resolvePluginSetupProvider(params: { ignoreAsyncSetupRegisterResult(result); } } catch { - setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } - setCachedSetupValue(setupProviderCache, cacheKey, matchedProvider ?? null); return matchedProvider; } @@ -698,12 +605,6 @@ export function resolvePluginSetupCliBackend(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): SetupCliBackendEntry | undefined { - const cacheKey = buildSetupCliBackendCacheKey(params); - const cached = getCachedSetupValue(setupCliBackendCache, cacheKey); - if (cached.hit) { - return cached.value ?? undefined; - } - const normalized = normalizeProviderId(params.backend); const env = params.env ?? process.env; @@ -721,13 +622,11 @@ export function resolvePluginSetupCliBackend(params: { listIds: listSetupCliBackendIds, }); if (!record) { - setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } const setupRegistration = resolveSetupRegistration(record); if (!setupRegistration) { - setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } @@ -760,12 +659,10 @@ export function resolvePluginSetupCliBackend(params: { ignoreAsyncSetupRegisterResult(result); } } catch { - setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } const resolvedEntry = matchedBackend ? { pluginId: record.id, backend: matchedBackend } : null; - setCachedSetupValue(setupCliBackendCache, cacheKey, resolvedEntry); return resolvedEntry ?? undefined; } diff --git a/src/plugins/test-helpers/cold-plugin-fixtures.ts b/src/plugins/test-helpers/cold-plugin-fixtures.ts index 3fa98d5fc3d..7c9dc79a967 100644 --- a/src/plugins/test-helpers/cold-plugin-fixtures.ts +++ b/src/plugins/test-helpers/cold-plugin-fixtures.ts @@ -116,8 +116,6 @@ export function createColdPluginHermeticEnv( OPENCLAW_BUNDLED_PLUGINS_DIR: options.bundledPluginsDir, OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: options.disablePersistedRegistry === false ? undefined : "1", - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", OPENCLAW_VERSION: "2026.4.25", VITEST: "true", }; diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index ca06f979dff..09ce1934136 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -250,7 +250,7 @@ describe("resolvePluginWebFetchProviders", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); - it("uses the active registry workspace for candidate discovery and snapshot loads when workspaceDir is omitted", () => { + it("uses the active registry workspace for candidate discovery when workspaceDir is omitted", () => { const env = createWebFetchEnv(); const rawConfig = createFirecrawlAllowConfig(); @@ -280,7 +280,7 @@ describe("resolvePluginWebFetchProviders", () => { ); }); - it("invalidates web-fetch snapshot memoization when the active registry workspace changes", () => { + it("resolves web-fetch providers for each active registry workspace", () => { const env = createWebFetchEnv(); const config = createFirecrawlAllowConfig(); diff --git a/src/plugins/web-fetch-providers.runtime.ts b/src/plugins/web-fetch-providers.runtime.ts index 4048a5bbeb2..bd0e91b4ad7 100644 --- a/src/plugins/web-fetch-providers.runtime.ts +++ b/src/plugins/web-fetch-providers.runtime.ts @@ -12,15 +12,13 @@ import { resolveManifestDeclaredWebProviderCandidatePluginIds, } from "./web-provider-resolution-shared.js"; import { - createWebProviderSnapshotCache, resolvePluginWebProviders, resolveRuntimeWebProviders, } from "./web-provider-runtime-shared.js"; -let webFetchProviderSnapshotCache = createWebProviderSnapshotCache(); - function resetWebFetchProviderSnapshotCacheForTests() { - webFetchProviderSnapshotCache = createWebProviderSnapshotCache(); + // Web provider snapshots are no longer memoized. Keep the test hook as a + // compatibility no-op for older reset paths. } export const __testing = { @@ -68,7 +66,6 @@ export function resolvePluginWebFetchProviders(params: { origin?: PluginManifestRecord["origin"]; }): PluginWebFetchProviderEntry[] { return resolvePluginWebProviders(params, { - snapshotCache: webFetchProviderSnapshotCache, resolveBundledResolutionConfig: resolveBundledWebFetchResolutionConfig, resolveCandidatePluginIds: resolveWebFetchCandidatePluginIds, mapRegistryProviders: mapRegistryWebFetchProviders, @@ -85,7 +82,6 @@ export function resolveRuntimeWebFetchProviders(params: { origin?: PluginManifestRecord["origin"]; }): PluginWebFetchProviderEntry[] { return resolveRuntimeWebProviders(params, { - snapshotCache: webFetchProviderSnapshotCache, resolveBundledResolutionConfig: resolveBundledWebFetchResolutionConfig, resolveCandidatePluginIds: resolveWebFetchCandidatePluginIds, mapRegistryProviders: mapRegistryWebFetchProviders, diff --git a/src/plugins/web-provider-resolution-shared.test.ts b/src/plugins/web-provider-resolution-shared.test.ts deleted file mode 100644 index 38e658fbbc8..00000000000 --- a/src/plugins/web-provider-resolution-shared.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildWebProviderSnapshotCacheKey, - mapRegistryProviders, -} from "./web-provider-resolution-shared.js"; - -describe("web-provider-resolution-shared", () => { - it("distinguishes explicit empty plugin scopes in cache keys", () => { - const unscoped = buildWebProviderSnapshotCacheKey({ - envKey: "demo", - }); - const scopedEmpty = buildWebProviderSnapshotCacheKey({ - envKey: "demo", - onlyPluginIds: [], - }); - - expect(scopedEmpty).not.toBe(unscoped); - }); - - it("treats explicit empty plugin scopes as scoped-empty when mapping providers", () => { - const providers = mapRegistryProviders({ - entries: [ - { - pluginId: "alpha", - provider: { id: "alpha-provider" }, - }, - { - pluginId: "beta", - provider: { id: "beta-provider" }, - }, - ], - onlyPluginIds: [], - sortProviders: (values) => values, - }); - - expect(providers).toEqual([]); - }); -}); diff --git a/src/plugins/web-provider-resolution-shared.ts b/src/plugins/web-provider-resolution-shared.ts index 428c500f3b4..8cfa6ccadec 100644 --- a/src/plugins/web-provider-resolution-shared.ts +++ b/src/plugins/web-provider-resolution-shared.ts @@ -2,11 +2,7 @@ import { resolveBundledPluginCompatibleLoadValues } from "./activation-context.j import type { PluginLoadOptions } from "./loader.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; -import { - createPluginIdScopeSet, - normalizePluginIdScope, - serializePluginIdScope, -} from "./plugin-scope.js"; +import { createPluginIdScopeSet, normalizePluginIdScope } from "./plugin-scope.js"; export type WebProviderContract = "webSearchProviders" | "webFetchProviders"; export type WebProviderConfigKey = "webSearch" | "webFetch"; @@ -182,28 +178,6 @@ export function resolveBundledWebProviderResolutionConfig(params: { }; } -export function buildWebProviderSnapshotCacheKey(params: { - config?: PluginLoadOptions["config"]; - workspaceDir?: string; - bundledAllowlistCompat?: boolean; - onlyPluginIds?: readonly string[]; - origin?: PluginManifestRecord["origin"]; - envKey: string | Record; -}): string { - const envKey = - typeof params.envKey === "string" - ? params.envKey - : Object.entries(params.envKey).toSorted(([left], [right]) => left.localeCompare(right)); - const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds); - return JSON.stringify({ - workspaceDir: params.workspaceDir ?? "", - bundledAllowlistCompat: params.bundledAllowlistCompat === true, - origin: params.origin ?? "", - onlyPluginIds: serializePluginIdScope(onlyPluginIds), - env: envKey, - }); -} - export function mapRegistryProviders(params: { entries: readonly { pluginId: string; provider: TProvider }[]; onlyPluginIds?: readonly string[]; diff --git a/src/plugins/web-provider-runtime-shared.test.ts b/src/plugins/web-provider-runtime-shared.test.ts index 9b53e4a0ecd..b3ffc160cc4 100644 --- a/src/plugins/web-provider-runtime-shared.test.ts +++ b/src/plugins/web-provider-runtime-shared.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ isPluginRegistryLoadInFlight: vi.fn(() => false), loadOpenClawPlugins: vi.fn(), resolveCompatibleRuntimePluginRegistry: vi.fn(), + resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)), resolveRuntimePluginRegistry: vi.fn(), getActivePluginRegistryWorkspaceDir: vi.fn(() => undefined), buildPluginRuntimeLoadOptionsFromValues: vi.fn( @@ -23,6 +24,7 @@ vi.mock("./loader.js", () => ({ isPluginRegistryLoadInFlight: mocks.isPluginRegistryLoadInFlight, loadOpenClawPlugins: mocks.loadOpenClawPlugins, resolveCompatibleRuntimePluginRegistry: mocks.resolveCompatibleRuntimePluginRegistry, + resolvePluginRegistryLoadCacheKey: mocks.resolvePluginRegistryLoadCacheKey, resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry, })); @@ -35,13 +37,12 @@ vi.mock("./runtime/load-context.js", () => ({ createPluginRuntimeLoaderLogger: mocks.createPluginRuntimeLoaderLogger, })); -let createWebProviderSnapshotCache: typeof import("./web-provider-runtime-shared.js").createWebProviderSnapshotCache; let resolvePluginWebProviders: typeof import("./web-provider-runtime-shared.js").resolvePluginWebProviders; let resolveRuntimeWebProviders: typeof import("./web-provider-runtime-shared.js").resolveRuntimeWebProviders; describe("web-provider-runtime-shared", () => { beforeAll(async () => { - ({ createWebProviderSnapshotCache, resolvePluginWebProviders, resolveRuntimeWebProviders } = + ({ resolvePluginWebProviders, resolveRuntimeWebProviders } = await import("./web-provider-runtime-shared.js")); }); @@ -50,6 +51,10 @@ describe("web-provider-runtime-shared", () => { mocks.isPluginRegistryLoadInFlight.mockReturnValue(false); mocks.loadOpenClawPlugins.mockReset(); mocks.resolveCompatibleRuntimePluginRegistry.mockReset(); + mocks.resolvePluginRegistryLoadCacheKey.mockReset(); + mocks.resolvePluginRegistryLoadCacheKey.mockImplementation((options: unknown) => + JSON.stringify(options), + ); mocks.resolveRuntimePluginRegistry.mockReset(); mocks.getActivePluginRegistryWorkspaceDir.mockReset(); mocks.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined); @@ -71,7 +76,6 @@ describe("web-provider-runtime-shared", () => { onlyPluginIds: [], }, { - snapshotCache: createWebProviderSnapshotCache(), resolveBundledResolutionConfig: () => ({ config: {}, activationSourceConfig: {}, @@ -104,7 +108,6 @@ describe("web-provider-runtime-shared", () => { onlyPluginIds: [], }, { - snapshotCache: createWebProviderSnapshotCache(), resolveBundledResolutionConfig: () => ({ config: {}, activationSourceConfig: {}, @@ -136,7 +139,6 @@ describe("web-provider-runtime-shared", () => { onlyPluginIds: ["alpha"], }, { - snapshotCache: createWebProviderSnapshotCache(), resolveBundledResolutionConfig: () => ({ config: {}, activationSourceConfig: {}, diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index 54fa992b1c6..d19f72bc7d8 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -1,10 +1,4 @@ -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withActivatedPluginIds } from "./activation-context.js"; -import { - buildPluginSnapshotCacheEnvKey, - resolvePluginSnapshotCacheTtlMs, - shouldUsePluginSnapshotCache, -} from "./cache-controls.js"; import { isPluginRegistryLoadInFlight, loadOpenClawPlugins, @@ -20,17 +14,6 @@ import { buildPluginRuntimeLoadOptionsFromValues, createPluginRuntimeLoaderLogger, } from "./runtime/load-context.js"; -import { buildWebProviderSnapshotCacheKey } from "./web-provider-resolution-shared.js"; - -type WebProviderSnapshotCacheEntry = { - expiresAt: number; - providers: TEntry[]; -}; - -export type WebProviderSnapshotCache = WeakMap< - OpenClawConfig, - WeakMap>> ->; export type ResolvePluginWebProvidersParams = { config?: PluginLoadOptions["config"]; @@ -45,7 +28,6 @@ export type ResolvePluginWebProvidersParams = { }; type ResolveWebProviderRuntimeDeps = { - snapshotCache: WebProviderSnapshotCache; resolveBundledResolutionConfig: (params: { config?: PluginLoadOptions["config"]; workspaceDir?: string; @@ -76,13 +58,6 @@ type ResolveWebProviderRuntimeDeps = { }) => TEntry[] | null; }; -export function createWebProviderSnapshotCache(): WebProviderSnapshotCache { - return new WeakMap< - OpenClawConfig, - WeakMap>> - >(); -} - function resolveWebProviderLoadOptions( params: ResolvePluginWebProvidersParams, deps: ResolveWebProviderRuntimeDeps, @@ -174,68 +149,21 @@ export function resolvePluginWebProviders( return deps.mapRegistryProviders({ registry, onlyPluginIds: pluginIds }); } - const cacheOwnerConfig = params.config; - const shouldMemoizeSnapshot = - params.activate !== true && params.cache !== true && shouldUsePluginSnapshotCache(env); - const cacheKey = buildWebProviderSnapshotCacheKey({ - config: cacheOwnerConfig, - workspaceDir, - bundledAllowlistCompat: params.bundledAllowlistCompat, - onlyPluginIds: params.onlyPluginIds, - origin: params.origin, - envKey: buildPluginSnapshotCacheEnvKey(env), - }); - if (cacheOwnerConfig && shouldMemoizeSnapshot) { - const configCache = deps.snapshotCache.get(cacheOwnerConfig); - const envCache = configCache?.get(env); - const cached = envCache?.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.providers; - } - } - const memoizeSnapshot = (providers: TEntry[]) => { - if (!cacheOwnerConfig || !shouldMemoizeSnapshot) { - return; - } - const ttlMs = resolvePluginSnapshotCacheTtlMs(env); - let configCache = deps.snapshotCache.get(cacheOwnerConfig); - if (!configCache) { - configCache = new WeakMap< - NodeJS.ProcessEnv, - Map> - >(); - deps.snapshotCache.set(cacheOwnerConfig, configCache); - } - let envCache = configCache.get(env); - if (!envCache) { - envCache = new Map>(); - configCache.set(env, envCache); - } - envCache.set(cacheKey, { - expiresAt: Date.now() + ttlMs, - providers, - }); - }; - const loadOptions = resolveWebProviderLoadOptions(params, deps); const compatible = resolveCompatibleRuntimePluginRegistry(loadOptions); if (compatible) { - const resolved = deps.mapRegistryProviders({ + return deps.mapRegistryProviders({ registry: compatible, onlyPluginIds: params.onlyPluginIds, }); - memoizeSnapshot(resolved); - return resolved; } if (isPluginRegistryLoadInFlight(loadOptions)) { return []; } - const resolved = deps.mapRegistryProviders({ + return deps.mapRegistryProviders({ registry: loadOpenClawPlugins(loadOptions), onlyPluginIds: params.onlyPluginIds, }); - memoizeSnapshot(resolved); - return resolved; } export function resolveRuntimeWebProviders( diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 5391e1aee69..f579f823237 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -189,27 +189,6 @@ function expectScopedWebSearchCandidates(pluginIds: readonly string[]) { ); } -function expectSnapshotMemoization(params: { - config: { plugins?: Record }; - env: NodeJS.ProcessEnv; - expectedLoaderCalls: number; -}) { - const runtimeParams = createSnapshotParams({ - config: params.config, - env: params.env, - }); - - const first = resolvePluginWebSearchProviders(runtimeParams); - const second = resolvePluginWebSearchProviders(runtimeParams); - - if (params.expectedLoaderCalls === 1) { - expect(second).toBe(first); - } else { - expect(second).not.toBe(first); - } - expectLoaderCallCount(params.expectedLoaderCalls); -} - function expectAutoEnabledWebSearchLoad(params: { rawConfig: { plugins?: Record }; expectedAllow: readonly string[]; @@ -471,14 +450,6 @@ describe("resolvePluginWebSearchProviders", () => { }), ); }); - it("memoizes snapshot provider resolution for the same config and env", () => { - expectSnapshotMemoization({ - config: createBraveAllowConfig(), - env: createWebSearchEnv(), - expectedLoaderCalls: 1, - }); - }); - it("reuses a compatible active registry for snapshot resolution when config is provided", () => { const { env, rawConfig } = createActiveBraveRegistryFixture(); @@ -509,7 +480,7 @@ describe("resolvePluginWebSearchProviders", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); - it("keys web-search snapshot memoization by the inherited active workspace", () => { + it("uses the inherited active workspace for each web-search resolution", () => { const env = createWebSearchEnv(); const rawConfig = createBraveAllowConfig(); @@ -530,7 +501,7 @@ describe("resolvePluginWebSearchProviders", () => { expectLoaderCallCount(2); }); - it("retains the snapshot cache when config contents change in place", () => { + it("resolves current config contents when config changes in place", () => { const config = createBraveAllowConfig(); const env = createWebSearchEnv({ OPENCLAW_HOME: "/tmp/openclaw-home-a" }); @@ -540,11 +511,11 @@ describe("resolvePluginWebSearchProviders", () => { mutate: () => { config.plugins = { allow: ["perplexity"] }; }, - expectedLoaderCalls: 1, + expectedLoaderCalls: 2, }); }); - it("invalidates the snapshot cache when env contents change in place", () => { + it("resolves current env contents when env changes in place", () => { const config = createBraveAllowConfig(); const env = createWebSearchEnv({ OPENCLAW_HOME: "/tmp/openclaw-home-a" }); @@ -558,28 +529,7 @@ describe("resolvePluginWebSearchProviders", () => { }); }); - it.each([ - { - title: "skips web-search snapshot memoization when plugin cache opt-outs are set", - env: { - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", - }, - }, - { - title: "skips web-search snapshot memoization when discovery cache ttl is zero", - env: { - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", - }, - }, - ])("$title", ({ env }) => { - expectSnapshotMemoization({ - config: createBraveAllowConfig(), - env: createWebSearchEnv(env), - expectedLoaderCalls: 2, - }); - }); - - it("does not leak host Vitest env into an explicit non-Vitest cache key", () => { + it("does not reuse snapshot provider loads across host Vitest env changes", () => { const originalVitest = process.env.VITEST; const config = {}; const env = createWebSearchEnv(); @@ -598,43 +548,9 @@ describe("resolvePluginWebSearchProviders", () => { } } - expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); - }); - - it("expires web-search snapshot memoization after the shortest plugin cache ttl", () => { - vi.useFakeTimers(); - const config = createBraveAllowConfig(); - const env = createWebSearchEnv({ - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5", - OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20", - }); - const runtimeParams = createSnapshotParams({ config, env }); - - resolvePluginWebSearchProviders(runtimeParams); - vi.advanceTimersByTime(4); - resolvePluginWebSearchProviders(runtimeParams); - vi.advanceTimersByTime(2); - resolvePluginWebSearchProviders(runtimeParams); - expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); }); - it("invalidates web-search snapshots when cache-control env values change in place", () => { - const config = createBraveAllowConfig(); - const env = createWebSearchEnv({ - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000", - }); - - expectSnapshotLoaderCalls({ - config, - env, - mutate: () => { - env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5"; - }, - expectedLoaderCalls: 2, - }); - }); - it.each([ { name: "prefers the active plugin registry for runtime resolution", diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 9738fb0fa04..8ad7324e55d 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -8,7 +8,6 @@ import { resolveManifestDeclaredWebProviderCandidatePluginIds, } from "./web-provider-resolution-shared.js"; import { - createWebProviderSnapshotCache, resolvePluginWebProviders, resolveRuntimeWebProviders, } from "./web-provider-runtime-shared.js"; @@ -17,10 +16,9 @@ import { sortWebSearchProviders, } from "./web-search-providers.shared.js"; -let webSearchProviderSnapshotCache = createWebProviderSnapshotCache(); - function resetWebSearchProviderSnapshotCacheForTests() { - webSearchProviderSnapshotCache = createWebProviderSnapshotCache(); + // Web provider snapshots are no longer memoized. Keep the test hook as a + // compatibility no-op for older reset paths. } export const __testing = { @@ -68,7 +66,6 @@ export function resolvePluginWebSearchProviders(params: { origin?: PluginManifestRecord["origin"]; }): PluginWebSearchProviderEntry[] { return resolvePluginWebProviders(params, { - snapshotCache: webSearchProviderSnapshotCache, resolveBundledResolutionConfig: resolveBundledWebSearchResolutionConfig, resolveCandidatePluginIds: resolveWebSearchCandidatePluginIds, mapRegistryProviders: mapRegistryWebSearchProviders, @@ -85,7 +82,6 @@ export function resolveRuntimeWebSearchProviders(params: { origin?: PluginManifestRecord["origin"]; }): PluginWebSearchProviderEntry[] { return resolveRuntimeWebProviders(params, { - snapshotCache: webSearchProviderSnapshotCache, resolveBundledResolutionConfig: resolveBundledWebSearchResolutionConfig, resolveCandidatePluginIds: resolveWebSearchCandidatePluginIds, mapRegistryProviders: mapRegistryWebSearchProviders, diff --git a/src/secrets/runtime-auth.integration.test-helpers.ts b/src/secrets/runtime-auth.integration.test-helpers.ts index 3d6f94e9c88..4e9f8e15e80 100644 --- a/src/secrets/runtime-auth.integration.test-helpers.ts +++ b/src/secrets/runtime-auth.integration.test-helpers.ts @@ -37,11 +37,9 @@ export function beginSecretsRuntimeIsolationForTest(): SecretsRuntimeEnvSnapshot const envSnapshot = captureEnv([ "OPENCLAW_BUNDLED_PLUGINS_DIR", "OPENCLAW_DISABLE_BUNDLED_PLUGINS", - "OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE", "OPENCLAW_VERSION", ]); delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE = "1"; delete process.env.OPENCLAW_VERSION; return envSnapshot; } diff --git a/src/secrets/runtime-core-snapshots.test.ts b/src/secrets/runtime-core-snapshots.test.ts index e9825908ecd..606b5f2de7a 100644 --- a/src/secrets/runtime-core-snapshots.test.ts +++ b/src/secrets/runtime-core-snapshots.test.ts @@ -46,11 +46,9 @@ function beginSecretsRuntimeIsolationForTest(): SecretsRuntimeEnvSnapshot { const envSnapshot = captureEnv([ "OPENCLAW_BUNDLED_PLUGINS_DIR", "OPENCLAW_DISABLE_BUNDLED_PLUGINS", - "OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE", "OPENCLAW_VERSION", ]); delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE = "1"; delete process.env.OPENCLAW_VERSION; return envSnapshot; } @@ -82,7 +80,6 @@ describe("secrets runtime snapshot core lanes", () => { return withEnvAsync( { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", OPENCLAW_VERSION: undefined, }, async () => diff --git a/src/secrets/runtime.gateway-auth.integration.test.ts b/src/secrets/runtime.gateway-auth.integration.test.ts index 7151dc9f54b..ec222597bc8 100644 --- a/src/secrets/runtime.gateway-auth.integration.test.ts +++ b/src/secrets/runtime.gateway-auth.integration.test.ts @@ -37,7 +37,6 @@ describe("secrets runtime snapshot gateway-auth integration", () => { await withEnvAsync( { OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", OPENCLAW_VERSION: undefined, }, async () => { diff --git a/src/secrets/runtime.integration.test-helpers.ts b/src/secrets/runtime.integration.test-helpers.ts index 73dd9b86cdb..a9a061c88f2 100644 --- a/src/secrets/runtime.integration.test-helpers.ts +++ b/src/secrets/runtime.integration.test-helpers.ts @@ -26,11 +26,9 @@ export function beginSecretsRuntimeIsolationForTest(): SecretsRuntimeEnvSnapshot const envSnapshot = captureEnv([ "OPENCLAW_BUNDLED_PLUGINS_DIR", "OPENCLAW_DISABLE_BUNDLED_PLUGINS", - "OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE", "OPENCLAW_VERSION", ]); delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - process.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE = "1"; delete process.env.OPENCLAW_VERSION; return envSnapshot; } diff --git a/test/setup.shared.ts b/test/setup.shared.ts index 6e21ea75686..f43c52ac9e4 100644 --- a/test/setup.shared.ts +++ b/test/setup.shared.ts @@ -40,9 +40,6 @@ process.env.VITEST = "true"; // Tests frequently point bundled plugin discovery at temp fixture roots. Production still rejects // arbitrary OPENCLAW_BUNDLED_PLUGINS_DIR overrides unless this Vitest-only opt-in is present. process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR ??= "1"; -// Config validation walks plugin manifests; keep an aggressive cache in tests to avoid -// repeated filesystem discovery across suites/workers. -process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ??= "60000"; // Vitest fork workers can load transitive lockfile helpers many times per worker. // Raise listener budget to avoid noisy MaxListeners warnings and warning-stack overhead. const TEST_PROCESS_MAX_LISTENERS = 256;