refactor(plugins): simplify plugin cache boundaries

This commit is contained in:
Peter Steinberger
2026-04-29 03:25:14 +01:00
parent 86c5f378d6
commit 7a5b419843
92 changed files with 986 additions and 2624 deletions

View File

@@ -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 `<system-reminder>` and `<previous_response>` 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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: {

View File

@@ -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 = {

View File

@@ -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({

View File

@@ -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<string, ChannelPlugin>;
secretsById: Map<string, ChannelPlugin["secrets"] | null>;
missingIds: Set<string>;
};
const cachedBootstrapPluginsByRoot = new Map<string, CachedBootstrapPlugins>();
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<ChannelPlugin> {
@@ -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 {}

View File

@@ -1,21 +1,13 @@
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
import { resolveBundledChannelRootScope } from "./bundled-root.js";
const bundledChannelPluginIdsByRoot = new Map<string, string[]>();
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[] {

View File

@@ -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);
});
});

View File

@@ -70,7 +70,7 @@ type GeneratedBundledChannelEntry = {
entry: BundledChannelEntryRuntimeContract;
};
type BundledChannelCacheContext = {
type BundledChannelLoadContext = {
pluginLoadInProgressIds: Set<ChannelId>;
setupPluginLoadInProgressIds: Set<ChannelId>;
entryLoadInProgressIds: Set<ChannelId>;
@@ -289,10 +289,7 @@ function loadGeneratedBundledChannelSetupEntry(params: {
}
}
const cachedBundledChannelMetadata = new Map<string, readonly BundledChannelPluginMetadata[]>();
const bundledChannelCacheContexts = new Map<string, BundledChannelCacheContext>();
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<ChannelPlugin["config"]["inspectAccount"]> | 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<BundledChannelSetupEntryRuntimeContract["features"]>,
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<BundledChannelEntryRuntimeContract["features"]>,
): 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<ChannelPlugin["config"]["inspectAccount"]> | 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}`);

View File

@@ -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<ConfiguredBindingChannel, CompiledConfiguredBinding[]>;
};
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): {

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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();
});

View File

@@ -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<string, MessageToolApi | undefined>();
function loadBundledChannelMessageToolApi(channelId: string): MessageToolApi | undefined {
const cacheKey = channelId.trim();
if (messageToolApiCache.has(cacheKey)) {
return messageToolApiCache.get(cacheKey);
}
try {
const loaded = loadBundledPluginPublicArtifactModuleSync<MessageToolApi>({
return loadBundledPluginPublicArtifactModuleSync<MessageToolApi>({
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(),
};

View File

@@ -24,14 +24,7 @@ type ChannelPackageStateMetadata = {
export type ChannelPackageStateMetadataKey = "configuredState" | "persistedAuthState";
type ChannelPackageStateRegistry = {
catalog: PluginChannelCatalogEntry[];
entriesById: Map<string, PluginChannelCatalogEntry>;
checkerCache: Map<string, ChannelPackageStateChecker | null>;
};
const log = createSubsystemLogger("channels");
const registryCache = new Map<ChannelPackageStateMetadataKey, ChannelPackageStateRegistry>();
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;
}

View File

@@ -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<string, LoadedChannelPlugin>;
entriesById: Map<string, LoadedChannelPluginEntry>;
};
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);
}

View File

@@ -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<TValue> = (
export function createChannelRegistryLoader<TValue>(
resolveValue: ChannelRegistryValueResolver<TValue>,
): (id: ChannelId) => Promise<TValue | undefined> {
const cache = new Map<ChannelId, TValue>();
let lastRegistry: PluginRegistry | null = null;
return async (id: ChannelId): Promise<TValue | undefined> => {
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);
};
}

View File

@@ -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);
});
});

View File

@@ -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<BundledSessionKeyModule>({
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<BundledSessionKeyModule>({
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,
}),

View File

@@ -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<string, ChannelPlugin>;
};
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<string>();
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);
}

View File

@@ -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();
});

View File

@@ -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<string, ThreadBindingApi | undefined>();
function loadBundledChannelThreadBindingApi(channelId: string): ThreadBindingApi | undefined {
const cacheKey = channelId.trim();
if (threadBindingApiCache.has(cacheKey)) {
return threadBindingApiCache.get(cacheKey);
}
try {
const loaded = loadBundledPluginPublicArtifactModuleSync<ThreadBindingApi>({
return loadBundledPluginPublicArtifactModuleSync<ThreadBindingApi>({
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(),
};

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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", () => {

View File

@@ -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 =

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string> | 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<string> {
legacyWebSearchProviderIdSetCache ??= new Set(getLegacyWebSearchProviderIds());
return legacyWebSearchProviderIdSetCache;
return new Set(getLegacyWebSearchProviderIds());
}
function resolveLegacySearchConfig(raw: unknown): JsonRecord | undefined {

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 () => {

View File

@@ -20,12 +20,8 @@ export async function withTempHome<T>(fn: (home: string) => Promise<T>): 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,

View File

@@ -17,7 +17,6 @@ const ChannelModelByChannelSchema = z
.record(z.string(), z.record(z.string(), z.string()))
.optional();
let directChannelRuntimeSchemasCache: ReadonlyMap<string, ChannelConfigRuntimeSchema> | 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<string, ChannelConfigRuntimeSchema>).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<string, ChannelConfigRuntimeSchema>).set(
channelId,
collectedRuntime,
);
return collectedRuntime;
}
}

View File

@@ -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";
});

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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(

View File

@@ -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");

View File

@@ -49,10 +49,9 @@ export type BundledPluginMetadata = {
manifest: PluginManifest;
};
const bundledPluginMetadataCache = new Map<string, readonly BundledPluginMetadata[]>();
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(

View File

@@ -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 ?? "",
});
}

View File

@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
resolveRuntimePluginRegistry: vi.fn<
(params?: unknown) => ReturnType<typeof createEmptyPluginRegistry> | undefined
>(() => undefined),
resolvePluginRegistryLoadCacheKey: vi.fn((options: unknown) => JSON.stringify(options)),
loadPluginManifestRegistry: vi.fn<(params?: Record<string, unknown>) => 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);

View File

@@ -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<K extends CapabilityProviderRegistryKey> =
PluginRegistry[K][number] extends { provider: infer T } ? T : never;
type CapabilityProviderPluginIdCacheEntry = {
expiresAt: number;
pluginIds: string[];
};
const CAPABILITY_CONTRACT_KEY: Record<CapabilityProviderRegistryKey, CapabilityContractKey> = {
memoryEmbeddingProviders: "memoryEmbeddingProviders",
speechProviders: "speechProviders",
@@ -53,68 +43,14 @@ const CAPABILITY_CONTRACT_KEY: Record<CapabilityProviderRegistryKey, CapabilityC
musicGenerationProviders: "musicGenerationProviders",
};
const capabilityProviderPluginIdCache = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, CapabilityProviderPluginIdCacheEntry>>
>();
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<string, CapabilityProviderPluginIdCacheEntry>
>();
capabilityProviderPluginIdCache.set(params.cfg, configCache);
}
let envCache = configCache.get(params.env);
if (!envCache) {
envCache = new Map<string, CapabilityProviderPluginIdCacheEntry>();
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: {

View File

@@ -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([]);

View File

@@ -33,8 +33,6 @@ export type PluginActivationConfigSource = {
export type NormalizedPluginsConfig = SharedNormalizedPluginsConfig;
let bundledPluginAliasLookupCache: ReadonlyMap<string, string> | undefined;
const BUILT_IN_PLUGIN_ALIAS_FALLBACKS: ReadonlyArray<readonly [alias: string, pluginId: string]> = [
["openai-codex", "openai"],
["google-gemini-cli", "google"],
@@ -47,10 +45,6 @@ const BUILT_IN_PLUGIN_ALIAS_LOOKUP = new Map<string, string>([
]);
function getBundledPluginAliasLookup(): ReadonlyMap<string, string> {
if (bundledPluginAliasLookupCache) {
return bundledPluginAliasLookupCache;
}
const lookup = new Map<string, string>();
for (const plugin of listBundledPluginMetadata({ includeChannelConfigs: false })) {
const pluginId = normalizeOptionalLowercaseString(plugin.manifest.id);
@@ -73,7 +67,6 @@ function getBundledPluginAliasLookup(): ReadonlyMap<string, string> {
for (const [alias, pluginId] of BUILT_IN_PLUGIN_ALIAS_FALLBACKS) {
lookup.set(alias, pluginId);
}
bundledPluginAliasLookupCache = lookup;
return lookup;
}

View File

@@ -219,33 +219,6 @@ function resolveBundledManifestPluginIdsForContract(contract: ManifestContractKe
).toSorted((left, right) => left.localeCompare(right));
}
let providerContractRegistryCache: ProviderContractEntry[] | null = null;
let providerContractRegistryByPluginIdCache: Map<string, ProviderContractEntry[]> | 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<string, ProviderContractEntry[]>();
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<string, WebFetchProviderContractEntry[]>();
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<string, WebSearchProviderContractEntry[]>();
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<T>(load: () => T[]): T[] {

View File

@@ -27,6 +27,18 @@ function makeTempDir() {
const mkdirSafe = mkdirSafeDir;
function withOpenClawPackageArgv<T>(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> = {},
): 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<typeof discoverOpenClawPlugins>[0],
@@ -92,7 +115,7 @@ async function discoverWithStateDir(
return discoverOpenClawPlugins({ ...params, env: buildDiscoveryEnv(stateDir) });
}
function discoverWithCachedEnv(params: Parameters<typeof discoverOpenClawPlugins>[0]) {
function discoverWithEnv(params: Parameters<typeof discoverOpenClawPlugins>[0]) {
return discoverOpenClawPlugins(params);
}
@@ -277,17 +300,6 @@ function expectBundleCandidateMatch(params: {
}
}
function expectCachedDiscoveryPair(params: {
first: ReturnType<typeof discoverWithCachedEnv>;
second: ReturnType<typeof discoverWithCachedEnv>;
assert: (
first: ReturnType<typeof discoverWithCachedEnv>,
second: ReturnType<typeof discoverWithCachedEnv>,
) => 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<typeof discoverWithCachedEnv>,
second: ReturnType<typeof discoverWithCachedEnv>,
first: ReturnType<typeof discoverWithEnv>,
second: ReturnType<typeof discoverWithEnv>,
) => {
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<typeof discoverWithCachedEnv>,
second: ReturnType<typeof discoverWithCachedEnv>,
first: ReturnType<typeof discoverWithEnv>,
second: ReturnType<typeof discoverWithEnv>,
) => {
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,
});

View File

@@ -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<string, { expiresAt: number; result: PluginDiscoveryResult }>();
// 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<string>();
const realpathCache = new Map<string, string>();
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<string>();
const realpathCache = new Map<string, string>();
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<string>();
const realpathCache = new Map<string, string>();
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<string>();
const realpathCache = new Map<string, string>();
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<string>();
mergeDiscoveryResult(result, scopedResult, seenSources);

View File

@@ -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({

View File

@@ -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<string, PluginDoctorContractEntry[]>();
const doctorContractRecordCache = new Map<string, Map<string, PluginDoctorContractEntry | null>>();
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<string, PluginDoctorContractEntry | null> {
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();
}

View File

@@ -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",
},

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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<string, PluginManifestModelIdNormalizationProvider>
| undefined;
function isRecord(value: unknown): value is Record<string, unknown> {
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.
}

View File

@@ -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", () => {

View File

@@ -7,64 +7,17 @@ import {
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
type ManifestSuppressionCache = Map<string, readonly ManifestModelCatalogSuppressionEntry[]>;
let cacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>();
let cacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>
>();
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<NodeJS.ProcessEnv, ManifestSuppressionCache>();
cacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, ManifestSuppressionCache>
>();
// Manifest suppressions are read fresh. Keep the test hook as a no-op.
}
export function resolveManifestBuiltInModelSuppression(params: {

View File

@@ -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",
},

View File

@@ -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,

View File

@@ -1,10 +1,4 @@
export type PluginManifestRegistryCacheEntry = {
expiresAt: number;
registry: unknown;
};
export const pluginManifestRegistryCache = new Map<string, PluginManifestRegistryCacheEntry>();
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.
}

View File

@@ -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");

View File

@@ -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<string, PluginManifestChannelConfig> | 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;
}

View File

@@ -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",
};

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
function resolveHookProviderCacheBucket(env: NodeJS.ProcessEnv) {
let bucket = cachedHookProviders.get(env);
if (!bucket) {
bucket = new Map<string, ProviderPlugin[]>();
cachedHookProviders.set(env, bucket);
}
return bucket;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function projectPluginEntryForProviderHookCache(
pluginId: string,
entry: unknown,
fullConfigPluginIds: ReadonlySet<string>,
): 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<string>,
): 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<string>();
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<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
// 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;
}

View File

@@ -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<string, BundledProviderPolicySurface | null>();
function buildProviderPolicySurfaceCacheKey(providerId: string): string {
const bundledPluginsDir = resolveBundledPluginsDir();
return `${providerId}::${bundledPluginsDir ?? "<default>"}`;
}
function hasProviderPolicyHook(
mod: Record<string, unknown>,
): mod is Record<string, unknown> & 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);
}

View File

@@ -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;

View File

@@ -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<string>();
let catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
let catalogHookProviderIdCache = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
@@ -144,61 +140,12 @@ function resetExternalAuthFallbackWarningCacheForTest(): void {
}
function resetCatalogHookProvidersCacheForTest(): void {
catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
// 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<NodeJS.ProcessEnv, Map<string, string[]>>();
}
function resolveCatalogHookProviderIdCacheBucket(params: {
env: NodeJS.ProcessEnv;
}): Map<string, string[]> {
let bucket = catalogHookProviderIdCache.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
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<string, ProviderPlugin[]>();
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: {

View File

@@ -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());

View File

@@ -428,28 +428,8 @@ function dedupeSortedPluginIds(values: Iterable<string>): string[] {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
let owningProviderPluginIdsCache = new WeakMap<
NodeJS.ProcessEnv,
Map<string, string[] | undefined>
>();
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<string, string[] | undefined>
>();
// 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<string, string[] | undefined>();
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: {

View File

@@ -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[];

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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<PluginSetupRegistry>(MAX_SETUP_LOOKUP_CACHE_ENTRIES);
const setupProviderCache = new PluginLruCache<ProviderPlugin | null>(
MAX_SETUP_LOOKUP_CACHE_ENTRIES,
);
const setupCliBackendCache = new PluginLruCache<SetupCliBackendEntry | null>(
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<T>(cache: PluginLruCache<T>, key: string): PluginLruCacheResult<T> {
return cache.getResult(key);
}
function setCachedSetupValue<T>(cache: PluginLruCache<T>, 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;
}

View File

@@ -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",
};

View File

@@ -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();

View File

@@ -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<PluginWebFetchProviderEntry>();
function resetWebFetchProviderSnapshotCacheForTests() {
webFetchProviderSnapshotCache = createWebProviderSnapshotCache<PluginWebFetchProviderEntry>();
// 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,

View File

@@ -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([]);
});
});

View File

@@ -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, string>;
}): 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<TProvider extends { id: string }>(params: {
entries: readonly { pluginId: string; provider: TProvider }[];
onlyPluginIds?: readonly string[];

View File

@@ -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: {},

View File

@@ -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<TEntry> = {
expiresAt: number;
providers: TEntry[];
};
export type WebProviderSnapshotCache<TEntry> = WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, WebProviderSnapshotCacheEntry<TEntry>>>
>;
export type ResolvePluginWebProvidersParams = {
config?: PluginLoadOptions["config"];
@@ -45,7 +28,6 @@ export type ResolvePluginWebProvidersParams = {
};
type ResolveWebProviderRuntimeDeps<TEntry> = {
snapshotCache: WebProviderSnapshotCache<TEntry>;
resolveBundledResolutionConfig: (params: {
config?: PluginLoadOptions["config"];
workspaceDir?: string;
@@ -76,13 +58,6 @@ type ResolveWebProviderRuntimeDeps<TEntry> = {
}) => TEntry[] | null;
};
export function createWebProviderSnapshotCache<TEntry>(): WebProviderSnapshotCache<TEntry> {
return new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, WebProviderSnapshotCacheEntry<TEntry>>>
>();
}
function resolveWebProviderLoadOptions<TEntry>(
params: ResolvePluginWebProvidersParams,
deps: ResolveWebProviderRuntimeDeps<TEntry>,
@@ -174,68 +149,21 @@ export function resolvePluginWebProviders<TEntry>(
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<string, WebProviderSnapshotCacheEntry<TEntry>>
>();
deps.snapshotCache.set(cacheOwnerConfig, configCache);
}
let envCache = configCache.get(env);
if (!envCache) {
envCache = new Map<string, WebProviderSnapshotCacheEntry<TEntry>>();
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<TEntry>(

View File

@@ -189,27 +189,6 @@ function expectScopedWebSearchCandidates(pluginIds: readonly string[]) {
);
}
function expectSnapshotMemoization(params: {
config: { plugins?: Record<string, unknown> };
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<string, unknown> };
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",

View File

@@ -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<PluginWebSearchProviderEntry>();
function resetWebSearchProviderSnapshotCacheForTests() {
webSearchProviderSnapshotCache = createWebProviderSnapshotCache<PluginWebSearchProviderEntry>();
// 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,

View File

@@ -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;
}

View File

@@ -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 () =>

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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;